diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index 3f3f18acf..9c4bbc7cf 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -25,7 +25,7 @@ namespace API.Tests.Services; public class SiteThemeServiceTests { - private readonly ILogger _logger = Substitute.For>(); + private readonly ILogger _logger = Substitute.For>(); private readonly IEventHub _messageHub = Substitute.For(); private readonly DbConnection _connection; @@ -135,7 +135,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); await siteThemeService.Scan(); Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); @@ -148,7 +148,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); await siteThemeService.Scan(); Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); @@ -167,7 +167,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); await siteThemeService.Scan(); Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom")); @@ -188,7 +188,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); _context.SiteTheme.Add(new SiteTheme() { @@ -213,7 +213,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); _context.SiteTheme.Add(new SiteTheme() { @@ -241,7 +241,7 @@ public class SiteThemeServiceTests var filesystem = CreateFileSystem(); filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123")); var ds = new DirectoryService(Substitute.For>(), filesystem); - var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub); + var siteThemeService = new ThemeService(ds, _unitOfWork, _messageHub); _context.SiteTheme.Add(new SiteTheme() { diff --git a/API/API.csproj b/API/API.csproj index b844745c2..1099893a3 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -94,16 +94,12 @@ - - - - diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs index f6775d2dc..bf68e8641 100644 --- a/API/Controllers/ThemeController.cs +++ b/API/Controllers/ThemeController.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using API.Data; using API.DTOs.Theme; +using API.Extensions; using API.Services; using API.Services.Tasks; using Kavita.Common; @@ -13,13 +14,13 @@ namespace API.Controllers; public class ThemeController : BaseApiController { private readonly IUnitOfWork _unitOfWork; - private readonly ISiteThemeService _siteThemeService; + private readonly IThemeService _themeService; private readonly ITaskScheduler _taskScheduler; - public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler) + public ThemeController(IUnitOfWork unitOfWork, IThemeService themeService, ITaskScheduler taskScheduler) { _unitOfWork = unitOfWork; - _siteThemeService = siteThemeService; + _themeService = themeService; _taskScheduler = taskScheduler; } @@ -39,9 +40,9 @@ public class ThemeController : BaseApiController [Authorize("RequireAdminRole")] [HttpPost("update-default")] - public async Task UpdateDefault(UpdateDefaultSiteThemeDto dto) + public async Task UpdateDefault(UpdateDefaultThemeDto dto) { - await _siteThemeService.UpdateDefault(dto.ThemeId); + await _themeService.UpdateDefault(dto.ThemeId); return Ok(); } @@ -54,7 +55,7 @@ public class ThemeController : BaseApiController { try { - return Ok(await _siteThemeService.GetContent(themeId)); + return Ok(await _themeService.GetContent(themeId)); } catch (KavitaException ex) { diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 3fda79468..a896348dc 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -82,11 +82,12 @@ namespace API.Controllers existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; - existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode; existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize; existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate; existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection; preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme(); + existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName; + existingPreferences.PageLayoutMode = preferencesDto.BookReaderLayoutMode; existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id); // TODO: Remove this code - this overrides layout mode to be single until the mode is released diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs index e8b0460f9..7c44a1cd0 100644 --- a/API/DTOs/Theme/SiteThemeDto.cs +++ b/API/DTOs/Theme/SiteThemeDto.cs @@ -4,6 +4,9 @@ using API.Services; namespace API.DTOs.Theme; +/// +/// Represents a set of css overrides the user can upload to Kavita and will load into webui +/// public class SiteThemeDto { public int Id { get; set; } diff --git a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs b/API/DTOs/Theme/UpdateDefaultThemeDto.cs similarity index 64% rename from API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs rename to API/DTOs/Theme/UpdateDefaultThemeDto.cs index d4bdb8e09..0f2b129f3 100644 --- a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs +++ b/API/DTOs/Theme/UpdateDefaultThemeDto.cs @@ -1,6 +1,6 @@ namespace API.DTOs.Theme; -public class UpdateDefaultSiteThemeDto +public class UpdateDefaultThemeDto { public int ThemeId { get; set; } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 4bfcb2d77..95833fa81 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -1,4 +1,5 @@ -using API.Entities; +using API.DTOs.Theme; +using API.Entities; using API.Entities.Enums; namespace API.DTOs @@ -74,5 +75,7 @@ namespace API.DTOs /// /// Should default to Dark public SiteTheme Theme { get; set; } + public string BookReaderThemeName { get; set; } + public BookPageLayoutMode BookReaderLayoutMode { get; set; } } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 90a6718f4..4f0a212b4 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -44,33 +44,40 @@ namespace API.Data public DbSet SeriesRelation { get; set; } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - base.OnModelCreating(modelBuilder); + base.OnModelCreating(builder); - modelBuilder.Entity() + builder.Entity() .HasMany(ur => ur.UserRoles) .WithOne(u => u.User) .HasForeignKey(ur => ur.UserId) .IsRequired(); - modelBuilder.Entity() + builder.Entity() .HasMany(ur => ur.UserRoles) .WithOne(u => u.Role) .HasForeignKey(ur => ur.RoleId) .IsRequired(); - modelBuilder.Entity() + builder.Entity() .HasOne(pt => pt.Series) .WithMany(p => p.Relations) .HasForeignKey(pt => pt.SeriesId) .OnDelete(DeleteBehavior.ClientCascade); - modelBuilder.Entity() + builder.Entity() .HasOne(pt => pt.TargetSeries) .WithMany(t => t.RelationOf) .HasForeignKey(pt => pt.TargetSeriesId); + + builder.Entity() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); } diff --git a/API/Data/Migrations/20220508162841_BookReaderUpdate.Designer.cs b/API/Data/Migrations/20220508162841_BookReaderUpdate.Designer.cs new file mode 100644 index 000000000..b8e7c6082 --- /dev/null +++ b/API/Data/Migrations/20220508162841_BookReaderUpdate.Designer.cs @@ -0,0 +1,1523 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20220508162841_BookReaderUpdate")] + partial class BookReaderUpdate + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220508162841_BookReaderUpdate.cs b/API/Data/Migrations/20220508162841_BookReaderUpdate.cs new file mode 100644 index 000000000..6df40e5fd --- /dev/null +++ b/API/Data/Migrations/20220508162841_BookReaderUpdate.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class BookReaderUpdate : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "BookReaderDarkMode", + table: "AppUserPreferences", + newName: "PageLayoutMode"); + + migrationBuilder.AlterColumn( + name: "BackgroundColor", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "#000000", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "BookThemeName", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + defaultValue: "Dark"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BookThemeName", + table: "AppUserPreferences"); + + migrationBuilder.RenameColumn( + name: "PageLayoutMode", + table: "AppUserPreferences", + newName: "BookReaderDarkMode"); + + migrationBuilder.AlterColumn( + name: "BackgroundColor", + table: "AppUserPreferences", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true, + oldDefaultValue: "#000000"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index e8f08eace..1c03ac40b 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "6.0.3"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.4"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -166,10 +166,9 @@ namespace API.Data.Migrations .HasColumnType("INTEGER"); b.Property("BackgroundColor") - .HasColumnType("TEXT"); - - b.Property("BookReaderDarkMode") - .HasColumnType("INTEGER"); + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); b.Property("BookReaderFontFamily") .HasColumnType("TEXT"); @@ -189,9 +188,17 @@ namespace API.Data.Migrations b.Property("BookReaderTapToPaginate") .HasColumnType("INTEGER"); + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + b.Property("LayoutMode") .HasColumnType("INTEGER"); + b.Property("PageLayoutMode") + .HasColumnType("INTEGER"); + b.Property("PageSplitOption") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/SiteThemeRepository.cs b/API/Data/Repositories/SiteThemeRepository.cs index a95fcda23..98f9c8c87 100644 --- a/API/Data/Repositories/SiteThemeRepository.cs +++ b/API/Data/Repositories/SiteThemeRepository.cs @@ -19,7 +19,6 @@ public interface ISiteThemeRepository Task GetThemeDtoByName(string themeName); Task GetDefaultTheme(); Task> GetThemes(); - Task GetThemeById(int themeId); } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ec0088aba..9b7dacc2a 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index d35b82e39..6caa18b79 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -25,6 +25,7 @@ namespace API.Entities /// /// public ReaderMode ReaderMode { get; set; } + /// /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// @@ -42,10 +43,6 @@ namespace API.Entities /// public string BackgroundColor { get; set; } = "#000000"; /// - /// Book Reader Option: Should the background color be dark - /// - public bool BookReaderDarkMode { get; set; } = true; - /// /// Book Reader Option: Override extra Margin /// public int BookReaderMargin { get; set; } = 15; @@ -74,7 +71,17 @@ namespace API.Entities /// /// Should default to Dark public SiteTheme Theme { get; set; } - + /// + /// Book Reader Option: The color theme to decorate the book contents + /// + /// Should default to Dark + public string BookThemeName { get; set; } = "Dark"; + /// + /// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height, + /// 2 column is fit to height, 2 columns + /// + /// Defaults to Default + public BookPageLayoutMode PageLayoutMode { get; set; } = BookPageLayoutMode.Default; public AppUser AppUser { get; set; } diff --git a/API/Entities/Enums/BookPageLayoutMode.cs b/API/Entities/Enums/BookPageLayoutMode.cs new file mode 100644 index 000000000..dc61b5a1e --- /dev/null +++ b/API/Entities/Enums/BookPageLayoutMode.cs @@ -0,0 +1,13 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +public enum BookPageLayoutMode +{ + [Description("Default")] + Default = 0, + [Description("1 Column")] + Column1 = 1, + [Description("2 Column")] + Column2 = 2 +} diff --git a/API/Entities/Interfaces/ITheme.cs b/API/Entities/Interfaces/ITheme.cs new file mode 100644 index 000000000..216136569 --- /dev/null +++ b/API/Entities/Interfaces/ITheme.cs @@ -0,0 +1,15 @@ +using API.Entities.Enums.Theme; + +namespace API.Entities.Interfaces; + +/// +/// A theme in some kind +/// +public interface ITheme +{ + public string Name { get; set; } + public string NormalizedName { get; set; } + public string FileName { get; set; } + public bool IsDefault { get; set; } + public ThemeProvider Provider { get; set; } +} diff --git a/API/Entities/SiteTheme.cs b/API/Entities/SiteTheme.cs index 87ebe95b1..a4847a7d6 100644 --- a/API/Entities/SiteTheme.cs +++ b/API/Entities/SiteTheme.cs @@ -8,7 +8,7 @@ namespace API.Entities; /// /// Represents a set of css overrides the user can upload to Kavita and will load into webui /// -public class SiteTheme : IEntityDate +public class SiteTheme : IEntityDate, ITheme { public int Id { get; set; } /// @@ -23,6 +23,7 @@ public class SiteTheme : IEntityDate /// File path to the content. Stored under . /// Must be a .css file /// + /// System provided themes use an alternative location as they are packaged with the app public string FileName { get; set; } /// /// Only one theme can have this. Will auto-set this as default for new user accounts diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index a4f51c67d..63e9dfdb9 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -40,7 +40,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 0a292934a..db4bb8b3c 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -107,7 +107,13 @@ namespace API.Helpers CreateMap() .ForMember(dest => dest.Theme, opt => - opt.MapFrom(src => src.Theme)); + opt.MapFrom(src => src.Theme)) + .ForMember(dest => dest.BookReaderThemeName, + opt => + opt.MapFrom(src => src.BookThemeName)) + .ForMember(dest => dest.BookReaderLayoutMode, + opt => + opt.MapFrom(src => src.PageLayoutMode)); CreateMap(); diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 67b97f9c2..c17ecc716 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -43,7 +43,7 @@ namespace API.Parser MatchOptions, RegexTimeout); - private static readonly string XmlRegexExtensions = @"\.xml"; + private const string XmlRegexExtensions = @"\.xml"; private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, MatchOptions, RegexTimeout); private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, @@ -999,7 +999,7 @@ namespace API.Parser public static bool HasBlacklistedFolderInPath(string path) { - return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._"); + return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle") || path.StartsWith("._") || path.Contains(".qpkg"); } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 42ec38331..4ccea99b4 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -183,6 +183,7 @@ namespace API.Services EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); + // Check if there are any background images and rewrite those urls EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); @@ -246,67 +247,62 @@ namespace API.Services private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) { var images = doc.DocumentNode.SelectNodes("//img"); - if (images != null) + if (images == null) return; + + foreach (var image in images) { - foreach (var image in images) + if (image.Name != "img") continue; + + string key = null; + if (image.Attributes["src"] != null) { - if (image.Name != "img") continue; + key = "src"; + } + else if (image.Attributes["xlink:href"] != null) + { + key = "xlink:href"; + } - // Need to do for xlink:href - if (image.Attributes["src"] != null) + if (string.IsNullOrEmpty(key)) continue; + + var imageFile = GetKeyForImage(book, image.Attributes[key].Value); + image.Attributes.Remove(key); + image.Attributes.Add(key, $"{apiBase}" + imageFile); + + // Add a custom class that the reader uses to ensure images stay within reader + image.AddClass("kavita-scale-width"); + } + + } + + /// + /// Returns the image key associated with the file. Contains some basic fallback logic. + /// + /// + /// + /// + private static string GetKeyForImage(EpubBookRef book, string imageFile) + { + if (!book.Content.Images.ContainsKey(imageFile)) + { + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) + { + imageFile = correctedKey; + } + else if (imageFile.StartsWith("..")) + { + // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg + correctedKey = + book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty))); + if (correctedKey != null) { - var imageFile = image.Attributes["src"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - // TODO: Refactor the Key code to a method to allow the hacks to be tested - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } else if (imageFile.StartsWith("..")) - { - // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg - correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty))); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - - - - } - - image.Attributes.Remove("src"); - image.Attributes.Add("src", $"{apiBase}" + imageFile); + imageFile = correctedKey; } } } - images = doc.DocumentNode.SelectNodes("//image"); - if (images != null) - { - foreach (var image in images) - { - if (image.Name != "image") continue; - - if (image.Attributes["xlink:href"] != null) - { - var imageFile = image.Attributes["xlink:href"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - - image.Attributes.Remove("xlink:href"); - image.Attributes.Add("xlink:href", $"{apiBase}" + imageFile); - } - } - } + return imageFile; } private static string PrepareFinalHtml(HtmlDocument doc, HtmlNode body) diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index cfbd2b138..d5765bc57 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -69,7 +69,7 @@ namespace API.Services private readonly ILogger _logger; private static readonly Regex ExcludeDirectories = new Regex( - @"@eaDir|\.DS_Store|\.qpkg", + @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", RegexOptions.Compiled | RegexOptions.IgnoreCase); diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 1eb154214..dcd356d88 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -62,7 +62,7 @@ public class MetadataService : IMetadataService /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image private async Task UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) { - var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + var firstFile = chapter.Files.MinBy(x => x.Chapter); if (!_cacheHelper.ShouldUpdateCoverImage(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) return false; @@ -97,12 +97,13 @@ public class MetadataService : IMetadataService null, volume.Created, forceUpdate)) return false; volume.Chapters ??= new List(); - var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); + var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); if (firstChapter == null) return false; volume.CoverImage = firstChapter.CoverImage; await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume), false); + return true; } @@ -133,8 +134,7 @@ public class MetadataService : IMetadataService if (!_cacheHelper.CoverImageExists(coverImage)) { - coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) - .FirstOrDefault()?.CoverImage; + coverImage = series.Volumes[0].Chapters.MinBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting)?.CoverImage; } } series.CoverImage = firstCover?.CoverImage ?? coverImage; diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index d749c20ca..585bec476 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -36,7 +36,7 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; - private readonly ISiteThemeService _siteThemeService; + private readonly IThemeService _themeService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -45,7 +45,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - ISiteThemeService siteThemeService) + IThemeService themeService) { _cacheService = cacheService; _logger = logger; @@ -56,7 +56,7 @@ public class TaskScheduler : ITaskScheduler _cleanupService = cleanupService; _statsService = statsService; _versionUpdaterService = versionUpdaterService; - _siteThemeService = siteThemeService; + _themeService = themeService; } public async Task ScheduleTasks() @@ -131,7 +131,7 @@ public class TaskScheduler : ITaskScheduler public void ScanSiteThemes() { _logger.LogInformation("Starting Site Theme scan"); - BackgroundJob.Enqueue(() => _siteThemeService.Scan()); + BackgroundJob.Enqueue(() => _themeService.Scan()); } #endregion @@ -149,6 +149,7 @@ public class TaskScheduler : ITaskScheduler public void ScanLibrary(int libraryId) { _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); + // TODO: If a library scan is already queued up for libraryId, don't do anything BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId)); // When we do a scan, force cache to re-unpack in case page numbers change BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index a5c0f0d5d..a9a96b71f 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -301,6 +301,9 @@ public class ScannerService : IScannerService await CleanupDbEntities(); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); } @@ -712,7 +715,7 @@ public class ScannerService : IScannerService } } - // BUG: The issue here is that people is just from chapter, but series metadata might already have some people on it + // NOTE: The issue here is that people is just from chapter, but series metadata might already have some people on it // I might be able to filter out people that are in locked fields? var people = chapters.SelectMany(c => c.People).ToList(); PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index e0e1bc2d8..553730d3a 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -7,24 +6,23 @@ using API.Entities; using API.Entities.Enums.Theme; using API.SignalR; using Kavita.Common; -using Microsoft.AspNetCore.SignalR; namespace API.Services.Tasks; -public interface ISiteThemeService +public interface IThemeService { Task GetContent(int themeId); Task Scan(); Task UpdateDefault(int themeId); } -public class SiteThemeService : ISiteThemeService +public class ThemeService : IThemeService { private readonly IDirectoryService _directoryService; private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; - public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) + public ThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) { _directoryService = directoryService; _unitOfWork = unitOfWork; @@ -36,7 +34,6 @@ public class SiteThemeService : ISiteThemeService /// /// /// - /// public async Task GetContent(int themeId) { var theme = await _unitOfWork.SiteThemeRepository.GetThemeDto(themeId); @@ -55,7 +52,8 @@ public class SiteThemeService : ISiteThemeService { _directoryService.ExistOrCreate(_directoryService.SiteThemeDirectory); var reservedNames = Seed.DefaultThemes.Select(t => t.NormalizedName).ToList(); - var themeFiles = _directoryService.GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") + var themeFiles = _directoryService + .GetFilesWithExtension(Parser.Parser.NormalizePath(_directoryService.SiteThemeDirectory), @"\.css") .Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList(); var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); @@ -91,7 +89,8 @@ public class SiteThemeService : ISiteThemeService }); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, ProgressEventType.Updated)); + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, + ProgressEventType.Updated)); } @@ -116,10 +115,10 @@ public class SiteThemeService : ISiteThemeService } await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); - + MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); } + /// /// Removes the theme and any references to it from Pref and sets them to the default at the time. /// This commits to DB. diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 50bfa5039..be3ab0acf 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -66,6 +66,10 @@ namespace API.SignalR /// private const string SiteThemeProgress = "SiteThemeProgress"; /// + /// A custom book theme was removed or added + /// + private const string BookThemeProgress = "BookThemeProgress"; + /// /// A type of event that has progress (determinate or indeterminate). /// The underlying event will have a name to give details on how to handle. /// @@ -367,5 +371,21 @@ namespace API.SignalR } }; } + + public static SignalRMessage BookThemeProgressEvent(string subtitle, string themeName, string eventType) + { + return new SignalRMessage() + { + Name = BookThemeProgress, + Title = "Scanning Book Theme", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new + { + ThemeName = themeName, + } + }; + } } } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 7125068ac..debc74845 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -19,4 +19,4 @@ - \ No newline at end of file + diff --git a/UI/Web/src/app/_models/book-page-layout-mode.ts b/UI/Web/src/app/_models/book-page-layout-mode.ts new file mode 100644 index 000000000..aac6c3fdb --- /dev/null +++ b/UI/Web/src/app/_models/book-page-layout-mode.ts @@ -0,0 +1,5 @@ +export enum BookPageLayoutMode { + Default = 0, + Column1 = 1, + Column2 = 2, +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/site-theme-progress-event.ts b/UI/Web/src/app/_models/events/site-theme-progress-event.ts deleted file mode 100644 index 23fab2939..000000000 --- a/UI/Web/src/app/_models/events/site-theme-progress-event.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface SiteThemeProgressEvent { - themeName: string; -} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/theme-progress-event.ts b/UI/Web/src/app/_models/events/theme-progress-event.ts new file mode 100644 index 000000000..84120e32a --- /dev/null +++ b/UI/Web/src/app/_models/events/theme-progress-event.ts @@ -0,0 +1,3 @@ +export interface ThemeProgressEvent { + themeName: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/preferences/book-theme.ts b/UI/Web/src/app/_models/preferences/book-theme.ts new file mode 100644 index 000000000..4b487fb12 --- /dev/null +++ b/UI/Web/src/app/_models/preferences/book-theme.ts @@ -0,0 +1,26 @@ +import { ThemeProvider } from "./site-theme"; + +/** + * Theme for the the book reader contents + */ + export interface BookTheme { + name: string; + provider: ThemeProvider; + /** + * Main color (usually background color) that represents the theme + */ + colorHash: string; + isDefault: boolean; + /** + * Is this theme providing dark mode to the reader aka Should we style the reader controls to be dark mode + */ + isDarkTheme: boolean; + /** + * Used to identify the theme on style tag + */ + selector: string; + /** + * Inner HTML + */ + content: string; + } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 7fbdaf185..065eb577b 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,5 +1,6 @@ import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; +import { BookPageLayoutMode } from '../book-page-layout-mode'; import { PageSplitOption } from './page-split-option'; import { ReaderMode } from './reader-mode'; import { ReadingDirection } from './reading-direction'; @@ -16,15 +17,16 @@ export interface Preferences { layoutMode: LayoutMode; backgroundColor: string; showScreenHints: boolean; - + // Book Reader - bookReaderDarkMode: boolean; bookReaderMargin: number; bookReaderLineSpacing: number; bookReaderFontSize: number; bookReaderFontFamily: string; bookReaderTapToPaginate: boolean; bookReaderReadingDirection: ReadingDirection; + bookReaderThemeName: string; + bookReaderLayoutMode: BookPageLayoutMode; // Global theme: SiteTheme; @@ -35,3 +37,4 @@ export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automati export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.FitSplit}, {text: 'Right to Left', value: PageSplitOption.SplitRightToLeft}, {text: 'Left to Right', value: PageSplitOption.SplitLeftToRight}, {text: 'No Split', value: PageSplitOption.NoSplit}]; export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}]; export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}]; +export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}]; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index ac7619611..852d8a906 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -6,7 +6,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { environment } from 'src/environments/environment'; import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { SiteThemeProgressEvent } from '../_models/events/site-theme-progress-event'; +import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; import { User } from '../_models/user'; export enum EVENTS { @@ -157,7 +157,7 @@ export class MessageHubService { this.hubConnection.on(EVENTS.SiteThemeProgress, resp => { this.messagesSource.next({ event: EVENTS.SiteThemeProgress, - payload: resp.body as SiteThemeProgressEvent + payload: resp.body as ThemeProgressEvent }); }); diff --git a/UI/Web/src/app/_services/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts index 15e00b89d..7c4b07ea2 100644 --- a/UI/Web/src/app/_services/scroll.service.ts +++ b/UI/Web/src/app/_services/scroll.service.ts @@ -18,5 +18,12 @@ export class ScrollService { top: top, behavior: 'smooth' }); - } + } + + scrollToX(left: number, el: Element | Window = window) { + el.scroll({ + left: left, + behavior: 'auto' + }); + } } diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index 8165cc235..23dc8e90c 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -10,13 +10,13 @@ import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; import { EVENTS, MessageHubService } from './message-hub.service'; - @Injectable({ providedIn: 'root' }) export class ThemeService implements OnDestroy { public defaultTheme: string = 'dark'; + public defaultBookTheme: string = 'Dark'; private currentThemeSource = new ReplaySubject(1); public currentTheme$ = this.currentThemeSource.asObservable(); @@ -32,9 +32,9 @@ export class ThemeService implements OnDestroy { private readonly onDestroy = new Subject(); private renderer: Renderer2; private baseUrl = environment.apiUrl; - - constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient, + + constructor(rendererFactory: RendererFactory2, @Inject(DOCUMENT) private document: Document, private httpClient: HttpClient, messageHub: MessageHubService, private domSantizer: DomSanitizer, private confirmService: ConfirmService) { this.renderer = rendererFactory.createRenderer(null, null); @@ -47,7 +47,7 @@ export class ThemeService implements OnDestroy { if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; if (notificationEvent.eventType === 'ended') { - this.getThemes().subscribe(() => {}); + if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => {}); } }); } @@ -61,6 +61,10 @@ export class ThemeService implements OnDestroy { return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim(); } + getCssVariable(variable: string) { + return getComputedStyle(this.document.body).getPropertyValue(variable).trim(); + } + isDarkTheme() { return this.getColorScheme().toLowerCase() === 'dark'; } @@ -73,6 +77,13 @@ export class ThemeService implements OnDestroy { })); } + /** + * Used in book reader to remove all themes so book reader can provide custom theming options + */ + clearThemes() { + this.unsetThemes(); + } + setDefault(themeId: number) { return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => { // Refresh the cache when a default state is changed @@ -84,8 +95,25 @@ export class ThemeService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'theme/scan', {}); } + /** + * Sets the book theme on the body tag so css variable overrides can take place + * @param selector brtheme- prefixed string + */ + setBookTheme(selector: string) { + this.unsetBookThemes(); + this.renderer.addClass(this.document.querySelector('body'), selector); + } - setTheme(themeName: string) { + clearBookTheme() { + this.unsetBookThemes(); + } + + + /** + * Sets the theme as active. Will inject a style tag into document to load a custom theme and apply the selector to the body + * @param themeName + */ + setTheme(themeName: string) { const theme = this.themeCache.find(t => t.name.toLowerCase() === themeName.toLowerCase()); if (theme) { this.unsetThemes(); @@ -132,4 +160,10 @@ export class ThemeService implements OnDestroy { private unsetThemes() { this.themeCache.forEach(theme => this.document.body.classList.remove(theme.selector)); } + + private unsetBookThemes() { + Array.from(this.document.body.classList).filter(cls => cls.startsWith('brtheme-')).forEach(c => this.document.body.classList.remove(c)); + } + + } diff --git a/UI/Web/src/app/_services/toggle.service.ts b/UI/Web/src/app/_services/toggle.service.ts new file mode 100644 index 000000000..5b9e90bd4 --- /dev/null +++ b/UI/Web/src/app/_services/toggle.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@angular/core'; +import { NavigationStart, Router } from '@angular/router'; +import { filter, ReplaySubject, take } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ToggleService { + + toggleState: boolean = false; + + + private toggleStateSource: ReplaySubject = new ReplaySubject(1); + public toggleState$ = this.toggleStateSource.asObservable(); + + constructor(router: Router) { + router.events + .pipe(filter(event => event instanceof NavigationStart)) + .subscribe((event) => { + this.toggleState = false; + }); + this.toggleStateSource.next(false); + } + + toggle() { + this.toggleState = !this.toggleState; + this.toggleStateSource.pipe(take(1)).subscribe(state => { + this.toggleState = !state; + console.log('Toggle: ', this.toggleState) + this.toggleStateSource.next(this.toggleState); + }); + + } + + set(state: boolean) { + this.toggleState = state; + this.toggleStateSource.next(state); + } +} diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index b7c96c26f..926fb3218 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -26,7 +26,7 @@ import { NavModule } from './nav/nav.module'; AppRoutingModule, BrowserAnimationsModule, - SidenavModule, + SidenavModule, NavModule, ToastrModule.forRoot({ diff --git a/UI/Web/src/app/book-reader/_models/book-black-theme.ts b/UI/Web/src/app/book-reader/_models/book-black-theme.ts new file mode 100644 index 000000000..f0dcebc89 --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/book-black-theme.ts @@ -0,0 +1,114 @@ +// Important note about themes. Must have one section with .reader-container that contains color, background-color and rest of the styles must be scoped to .book-content +export const BookBlackTheme = ` +:root .brtheme-black { + /* General */ + --color-scheme: dark; + --bs-body-color: black; + --hr-color: rgba(239, 239, 239, 0.125); + --accent-bg-color: rgba(1, 4, 9, 0.5); + --accent-text-color: lightgrey; + --body-text-color: #efefef; + --btn-icon-filter: invert(1) grayscale(100%) brightness(200%); + + /* Drawer */ + --drawer-bg-color: #292929; + --drawer-text-color: white; + + /* Accordion */ + --accordion-header-text-color: rgba(74, 198, 148, 0.9); + --accordion-header-bg-color: rgba(52, 60, 70, 0.5); + --accordion-body-bg-color: #292929; + --accordion-body-border-color: rgba(239, 239, 239, 0.125); + --accordion-body-text-color: var(--body-text-color); + --accordion-header-collapsed-text-color: rgba(74, 198, 148, 0.9); + --accordion-header-collapsed-bg-color: #292929; + --accordion-button-focus-border-color: unset; + --accordion-button-focus-box-shadow: unset; + --accordion-active-body-bg-color: #292929; + + /* Buttons */ + --btn-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-primary-text-color: white; + --btn-primary-bg-color: var(--primary-color); + --btn-primary-border-color: var(--primary-color); + --btn-primary-hover-text-color: white; + --btn-primary-hover-bg-color: var(--primary-color-darker-shade); + --btn-primary-hover-border-color: var(--primary-color-darker-shade); + --btn-alt-bg-color: #424c72; + --btn-alt-border-color: #444f75; + --btn-alt-hover-bg-color: #3b4466; + --btn-alt-focus-bg-color: #343c59; + --btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-fa-icon-color: white; + --btn-disabled-bg-color: #343a40; + --btn-disabled-text-color: white; + --btn-disabled-border-color: #6c757d; + + /* Nav (Tabs) */ + --nav-tab-border-color: rgba(44, 118, 88, 0.7); + --nav-tab-text-color: var(--body-text-color); + --nav-tab-bg-color: var(--primary-color); + --nav-tab-hover-border-color: var(--primary-color); + --nav-tab-active-text-color: white; + --nav-tab-border-hover-color: transparent; + --nav-tab-hover-text-color: var(--body-text-color); + --nav-tab-hover-bg-color: transparent; + --nav-tab-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-border-bottom: rgba(44, 118, 88, 0.7); + --nav-tab-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-bottom: var(--bs-body-bg); + --nav-tab-hover-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-active-hover-bg-color: var(--primary-color); + --nav-link-bg-color: var(--primary-color); + --nav-link-active-text-color: white; + --nav-link-text-color: white; + + + + /* Reading Bar */ + --br-actionbar-button-text-color: white; + --br-actionbar-button-hover-border-color: #6c757d; + --br-actionbar-bg-color: black; +} +} + + + +.book-content *:not(input), .book-content *:not(select), .book-content *:not(code), .book-content *:not(:link), .book-content *:not(.ngx-toastr) { + color: #dcdcdc !important; +} + +.book-content code { + color: #e83e8c !important; +} + +.book-content :link, .book-content a { + color: #8db2e5 !important; +} + +.book-content img, .book-content img[src] { +z-index: 1; +filter: brightness(0.85) !important; +background-color: initial !important; +} + +.reader-container { + color: #dcdcdc !important; + background-image: none !important; + background-color: black !important; +} + +.book-content *:not(code), .book-content *:not(a) { + background-color: black; + box-shadow: none; + text-shadow: none; + border-radius: unset; + color: #dcdcdc !important; +} + +.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(211, 138, 138) !important} +.book-content :link:not(cite), :link .book-content *:not(cite) {color: #8db2e5 !important} +`; \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/_models/book-dark-theme.ts b/UI/Web/src/app/book-reader/_models/book-dark-theme.ts new file mode 100644 index 000000000..04be8b258 --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/book-dark-theme.ts @@ -0,0 +1,119 @@ +// Important note about themes. Styles must be scoped to .book-content if not css variable overrides +export const BookDarkTheme = ` +:root .brtheme-dark { + /* General */ + --color-scheme: dark; + --bs-body-color: #292929; + --hr-color: rgba(239, 239, 239, 0.125); + --accent-bg-color: rgba(1, 4, 9, 0.5); + --accent-text-color: lightgrey; + --body-text-color: #efefef; + --btn-icon-filter: invert(1) grayscale(100%) brightness(200%); + + /* Drawer */ + --drawer-bg-color: #292929; + --drawer-text-color: white; + + /* Accordion */ + --accordion-header-text-color: rgba(74, 198, 148, 0.9); + --accordion-header-bg-color: rgba(52, 60, 70, 0.5); + --accordion-body-bg-color: #292929; + --accordion-body-border-color: rgba(239, 239, 239, 0.125); + --accordion-body-text-color: var(--body-text-color); + --accordion-header-collapsed-text-color: rgba(74, 198, 148, 0.9); + --accordion-header-collapsed-bg-color: #292929; + --accordion-button-focus-border-color: unset; + --accordion-button-focus-box-shadow: unset; + --accordion-active-body-bg-color: #292929; + + /* Buttons */ + --btn-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-primary-text-color: white; + --btn-primary-bg-color: var(--primary-color); + --btn-primary-border-color: var(--primary-color); + --btn-primary-hover-text-color: white; + --btn-primary-hover-bg-color: var(--primary-color-darker-shade); + --btn-primary-hover-border-color: var(--primary-color-darker-shade); + --btn-alt-bg-color: #424c72; + --btn-alt-border-color: #444f75; + --btn-alt-hover-bg-color: #3b4466; + --btn-alt-focus-bg-color: #343c59; + --btn-alt-focus-boxshadow-color: rgb(255 255 255 / 50%); + --btn-fa-icon-color: white; + --btn-disabled-bg-color: #343a40; + --btn-disabled-text-color: white; + --btn-disabled-border-color: #6c757d; + + /* Nav (Tabs) */ + --nav-tab-border-color: rgba(44, 118, 88, 0.7); + --nav-tab-text-color: var(--body-text-color); + --nav-tab-bg-color: var(--primary-color); + --nav-tab-hover-border-color: var(--primary-color); + --nav-tab-active-text-color: white; + --nav-tab-border-hover-color: transparent; + --nav-tab-hover-text-color: var(--body-text-color); + --nav-tab-hover-bg-color: transparent; + --nav-tab-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-border-bottom: rgba(44, 118, 88, 0.7); + --nav-tab-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-top: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-left: rgba(44, 118, 88, 0.7); + --nav-tab-hover-border-bottom: var(--bs-body-bg); + --nav-tab-hover-border-right: rgba(44, 118, 88, 0.7); + --nav-tab-active-hover-bg-color: var(--primary-color); + --nav-link-bg-color: var(--primary-color); + --nav-link-active-text-color: white; + --nav-link-text-color: white; + + /* Checkboxes/Switch */ + --checkbox-checked-bg-color: var(--primary-color); + --checkbox-border-color: var(--input-focused-border-color); + --checkbox-focus-border-color: var(--primary-color); + --checkbox-focus-boxshadow-color: rgb(255 255 255 / 50%); + + + + /* Reading Bar */ + --br-actionbar-button-text-color: white; + --br-actionbar-button-hover-border-color: #6c757d; + --br-actionbar-bg-color: black; +} + + + +.book-content *:not(input), .book-content *:not(select), .book-content *:not(code), .book-content *:not(:link), .book-content *:not(.ngx-toastr) { + color: #dcdcdc !important; +} + +.book-content code { + color: #e83e8c !important; +} + +.book-content :link, .book-content a { + color: #8db2e5 !important; +} + +.book-content img, .book-content img[src] { +z-index: 1; +filter: brightness(0.85) !important; +background-color: initial !important; +} + +.reader-container { + color: #dcdcdc !important; + background-image: none !important; + background-color: #292929 !important; +} + +.book-content *:not(code), .book-content *:not(a) { + background-color: #292929; + box-shadow: none; + text-shadow: none; + border-radius: unset; + color: #dcdcdc !important; +} + +.book-content :visited, .book-content :visited *, .book-content :visited *[class] {color: rgb(211, 138, 138) !important} +.book-content :link:not(cite), :link .book-content *:not(cite) {color: #8db2e5 !important} +`; \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/_models/book-white-theme.ts b/UI/Web/src/app/book-reader/_models/book-white-theme.ts new file mode 100644 index 000000000..8061e2eb3 --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/book-white-theme.ts @@ -0,0 +1,7 @@ +// Important note about themes. Must have one section with .reader-container that contains color, background-color and rest of the styles must be scoped to .book-content +export const BookWhiteTheme = ` + :root() .brtheme-white { + --brtheme-link-text-color: green; + --brtheme-bg-color: lightgrey; + } +`; \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/_models/theme-font.ts b/UI/Web/src/app/book-reader/_models/theme-font.ts new file mode 100644 index 000000000..06d3648dd --- /dev/null +++ b/UI/Web/src/app/book-reader/_models/theme-font.ts @@ -0,0 +1,15 @@ +/** + * A font family to inject into the book reader + */ +export interface ThemeFont { + /** + * Name/Font-family + */ + fontFamily: string; + /** + * Where the font is loaded from? + */ + fontSrc: string; + format: 'truetype'; + +} \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/book-reader.module.ts b/UI/Web/src/app/book-reader/book-reader.module.ts index a572d1a96..50bd0060b 100644 --- a/UI/Web/src/app/book-reader/book-reader.module.ts +++ b/UI/Web/src/app/book-reader/book-reader.module.ts @@ -5,12 +5,14 @@ import { BookReaderRoutingModule } from './book-reader.router.module'; import { SharedModule } from '../shared/shared.module'; import { SafeStylePipe } from './safe-style.pipe'; import { ReactiveFormsModule } from '@angular/forms'; -import { NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { PipeModule } from '../pipe/pipe.module'; +import { NgbAccordionModule, NgbNavModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { TableOfContentsComponent } from './table-of-contents/table-of-contents.component'; +import { ReaderSettingsComponent } from './reader-settings/reader-settings.component'; @NgModule({ - declarations: [BookReaderComponent, SafeStylePipe], + declarations: [BookReaderComponent, SafeStylePipe, TableOfContentsComponent, ReaderSettingsComponent], imports: [ CommonModule, BookReaderRoutingModule, @@ -19,6 +21,9 @@ import { PipeModule } from '../pipe/pipe.module'; NgbProgressbarModule, NgbTooltipModule, PipeModule, + NgbTooltipModule, + NgbAccordionModule, // Drawer + NgbNavModule, // Drawer ], exports: [ BookReaderComponent, SafeStylePipe diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index 701aa46a5..0e84d47cc 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -2,74 +2,11 @@
Skip to main content - -
-

Book Settings - - -

-
-
-
-
- -
-
- - -
-
-
-
- - - {{pageStyles['font-size']}} - -
-
- - - {{pageStyles['line-height']}} - -
-
- - - {{pageStyles['margin-right']}} - -
-
- - -
-
- - - -
-
- - The ability to click the sides of the page to page left and right - The ability to click the sides of the page to page left and right - -
-
- - Put reader in fullscreen mode - - - - -
-
- -
-
+ +
+ Book Settings +
+
{{pageNum}}
@@ -79,51 +16,56 @@
{{maxPages - 1}}
-
-

Table of Contents

-
- This book does not have Table of Contents set in the metadata or a toc file -
-
- -
- - - -
+
+
+ + +
-
+
-
-
-
+ +
+
+
-
+
-
+
- {{bookTitle}} (Incognito Mode) + {{bookTitle}}
diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index 1d70b604d..c4a1784d4 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -28,135 +28,77 @@ src: url(../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype"); } +@font-face { + font-family: "OpenDyslexic2"; + src: url(../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf) format("opentype"); +} + +:root { + --br-actionbar-button-text-color: #6c757d; + --accordion-body-bg-color: black; + --accordion-header-bg-color: grey; + --br-actionbar-button-hover-border-color: #6c757d; + --br-actionbar-bg-color: white; +} + + $dark-form-background-no-opacity: rgb(1, 4, 9); $primary-color: #0062cc; + +// Drawer .control-container { padding-bottom: 5px; } -.table-of-contents li { - cursor: pointer; - - &.active { - font-weight: bold; - } -} - .page-stub { margin-top: 6px; padding-left: 2px; padding-right: 2px; } +// Drawer End + .fixed-top { z-index: 1022; } -.dark-mode { - - color: #dcdcdc !important; - background-image: none !important; - background-color: #292929 !important; - - *:not(code), *:not(a) { - background-color: #292929; - box-shadow: none; - text-shadow: none; - border-radius: unset; - color: #dcdcdc !important; - } - - *:not(input), *:not(code), *:not(:link) { - color: #dcdcdc !important; - } - - code { - color: #e83e8c !important; - } - - .btn-icon { - background-color: transparent; - } - - :link, a { - color: #8db2e5 !important; - } - - img, img[src] { - z-index: 1; - filter: brightness(0.85) !important; - background-color: initial !important; - } - - :visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important} - :link:not(cite), :link *:not(cite) {color: #8db2e5 !important} +.dark-mode .overlay { + opacity: 0; } -.reading-bar { - background-color: white; + +.action-bar { + background-color: var(--br-actionbar-bg-color); overflow: hidden; box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%); -} + max-height: 62px; -.dark-mode { - .reading-bar, .book-title, .drawer-body, .drawer-container { - background-color: $dark-form-background-no-opacity; + .book-title-text { + text-align: center; + text-overflow: ellipsis; } - button { - background-color: $dark-form-background-no-opacity; - } - - .btn { - &.btn-secondary { - border-color: transparent; - &:hover, &:focus { - border-color: #545b62; - } + @media(max-width: 875px) { + .book-title { + display: none; } - - &.btn-outline-secondary { - border-color: transparent; + } - &:hover, &:focus { - border-color: #545b62; - } - } - - span { - background-color: unset; - } - - i { - background-color: unset; - } - } -} - -::ng-deep .dark-mode .drawer-container { - .header, body, *:not(.progress-bar) { - background-color: $dark-form-background-no-opacity !important; - } -} - -@media(max-width: 875px) { .book-title { - display: none; + margin-top: 10px; + text-align: center; + text-transform: capitalize; } - -} - -.book-title { - margin-top: 10px; - text-align: center; - text-transform: capitalize; } + + .reading-section { max-height: 100vh; width: 100%; //overflow: auto; // This will break progress reporting + height: 100vh; } .reader-container { @@ -166,6 +108,40 @@ $primary-color: #0062cc; .book-content { position: relative; + padding-top: 20px; + padding-bottom: 20px; + margin: 0px 0px; + height: calc(var(--vh)*100); // This will ensure bottom bar extends to the bottom of the screen + + a, :link { + color: var(--brtheme-link-text-color); + } + + background-color: var(--brtheme-bg-color); +} + + + +// This is essentially fitting the text to height and when you press next you are scrolling over by page width +.column-layout-1 { + .book-content { + column-count: 1; + column-gap: 20px; + overflow: hidden; + word-break: break-word; + overflow-wrap: break-word; + } +} + +.column-layout-2 { + .book-content { + column-count: 2; + column-gap: 20px; + overflow: hidden; + word-break: break-word; + overflow-wrap: break-word; + } + } // A bunch of resets so books render correctly @@ -175,18 +151,15 @@ $primary-color: #0062cc; } } -.drawer-body { - padding-bottom: 20px; -} -.chapter-title { - padding-inline-start: 0px -} - -::ng-deep .scale-width { +// This is applied to images in the backend +::ng-deep .kavita-scale-width { max-width: 100%; object-fit: contain; object-position: top center; + break-inside: avoid; + break-before: column; + max-height: 100vh; } @@ -195,9 +168,8 @@ $primary-color: #0062cc; color: $primary-color; } -.dark-mode .overlay { - opacity: 0; -} + + .right { @@ -246,14 +218,17 @@ $primary-color: #0062cc; animation: fadein .5s both; } + + + .btn { &.btn-secondary { - color: #6c757d; + color: var(--br-actionbar-button-text-color); border-color: transparent; background-color: unset; &:hover, &:focus { - border-color: #545b62; + border-color: var(--br-actionbar-button-hover-border-color); } } @@ -262,18 +237,18 @@ $primary-color: #0062cc; background-color: unset; &:hover, &:focus { - border-color: #545b62; + border-color: var(--br-actionbar-button-hover-border-color); // #545b62; } } span { background-color: unset; - color: #6c757d; + color: var(--br-actionbar-button-text-color); // #6c757d; } i { background-color: unset; - color: #6c757d; + color: var(--br-actionbar-button-text-color); } &:active { diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index bf501d3b2..9a235dde1 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -1,18 +1,15 @@ import { AfterViewInit, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; import {DOCUMENT, Location} from '@angular/common'; -import { FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; import { forkJoin, fromEvent, Subject } from 'rxjs'; -import { debounceTime, take, takeUntil } from 'rxjs/operators'; +import { debounceTime, filter, take, takeUntil, tap } from 'rxjs/operators'; import { Chapter } from 'src/app/_models/chapter'; -import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; import { NavService } from 'src/app/_services/nav.service'; import { ReaderService } from 'src/app/_services/reader.service'; import { SeriesService } from 'src/app/_services/series.service'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; - import { BookService } from '../book.service'; import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service'; import { BookChapterItem } from '../_models/book-chapter-item'; @@ -23,16 +20,19 @@ import { ReadingDirection } from 'src/app/_models/preferences/reading-direction' import { MangaFormat } from 'src/app/_models/manga-format'; import { LibraryService } from 'src/app/_services/library.service'; import { LibraryType } from 'src/app/_models/library'; +import { BookTheme } from 'src/app/_models/preferences/book-theme'; +import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode'; +import { PageStyle } from '../reader-settings/reader-settings.component'; +import { User } from 'src/app/_models/user'; +import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; import { ThemeService } from 'src/app/_services/theme.service'; import { ScrollService } from 'src/app/_services/scroll.service'; +import { PAGING_DIRECTION } from 'src/app/manga-reader/_models/reader-enums'; -interface PageStyle { - 'font-family': string; - 'font-size': string; - 'line-height': string; - 'margin-left': string; - 'margin-right': string; +enum TabID { + Settings = 1, + TableOfContents = 2 } interface HistoryPoint { @@ -86,31 +86,44 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { * If this is true, no progress will be saved. */ incognitoMode: boolean = false; - + /** - * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. + * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order. */ readingListMode: boolean = false; + /** + * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors + */ chapters: Array = []; - + /** + * Current Page + */ pageNum = 0; + /** + * Max Pages + */ maxPages = 1; + /** + * This allows for exploration into different chapters + */ adhocPageHistory: Stack = new Stack(); /** * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls. * @see Stack */ - continuousChaptersStack: Stack = new Stack(); - - user!: User; + continuousChaptersStack: Stack = new Stack(); // TODO: See if this can be moved into reader service so we can reduce code duplication between readers + + activeTabId: TabID = TabID.Settings; drawerOpen = false; - isLoading = true; + isLoading = true; bookTitle: string = ''; - settingsForm: FormGroup = new FormGroup({}); - clickToPaginate = false; + clickToPaginate = false; + /** + * The boolean that decides if the clickToPaginate overlay is visible or not. + */ clickToPaginateVisualOverlay = false; clickToPaginateVisualOverlayTimeout: any = undefined; // For animation clickToPaginateVisualOverlayTimeout2: any = undefined; // For kicking off animation, giving enough time to render html @@ -157,20 +170,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { nextPageDisabled = false; /** - * Internal property used to capture all the different css properties to render on all elements + * Internal property used to capture all the different css properties to render on all elements. This is a cached version that is updated from reader-settings component */ pageStyles!: PageStyle; - /** - * List of all font families user can select from - */ - fontFamilies: Array = []; - - darkMode = false; + + darkMode = true; backgroundColor: string = 'white'; - readerStyles: string = ''; - darkModeStyleElem!: HTMLElement; - topOffset: number = 0; // Offset for drawer and rendering canvas + /** + * Offset for drawer and rendering canvas. Fixed to 62px. + */ + topOffset: number = 62; /** * Used for showing/hiding bottom action bar. Calculates if there is enough scroll to show it. * Will hide if all content in book is absolute positioned @@ -191,35 +201,39 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ libraryType: LibraryType = LibraryType.Book; - /** - * Hack: Override background color for reader and restore it onDestroy - */ - originalBodyColor: string | undefined; - /** * If the web browser is in fullscreen mode */ isFullscreen: boolean = false; - darkModeStyles = ` - *:not(input), *:not(select), *:not(code), *:not(:link), *:not(.ngx-toastr) { - color: #dcdcdc !important; - } + /** + * How to render the page content + */ + layoutMode: BookPageLayoutMode = BookPageLayoutMode.Default; - code { - color: #e83e8c !important; - } - :link, a { - color: #8db2e5 !important; - } + /** + * Width of the document (in non-column layout), used for column layout virtual paging + */ + windowWidth: number = 0; + windowHeight: number = 0; - img, img[src] { - z-index: 1; - filter: brightness(0.85) !important; - background-color: initial !important; - } - `; + user!: User; + + /** + * Used to keep track of direction user is paging, to help with virtual paging on column layout + */ + pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; + + + + get BookPageLayoutMode() { + return BookPageLayoutMode; + } + + get TabID(): typeof TabID { + return TabID; + } get ReadingDirection(): typeof ReadingDirection { return ReadingDirection; @@ -252,122 +266,105 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return this.pageNum === 0; } - get drawerBackgroundColor() { - return this.darkMode ? '#010409': '#fff'; + get ColumnWidth() { + switch (this.layoutMode) { + case BookPageLayoutMode.Default: + return 'unset'; + case BookPageLayoutMode.Column1: + return (this.windowWidth /2) + 'px'; + case BookPageLayoutMode.Column2: + return ((this.windowWidth / 4)) + 'px'; + } } + get ColumnHeight() { + if (this.layoutMode !== BookPageLayoutMode.Default) { + // Take the height after page loads, subtract the top/bottom bar + return this.windowHeight - (this.topOffset *2) + 'px'; + } + return 'unset'; + } + + get ColumnLayout() { + switch (this.layoutMode) { + case BookPageLayoutMode.Default: + return ''; + case BookPageLayoutMode.Column1: + return 'column-layout-1'; + case BookPageLayoutMode.Column2: + return 'column-layout-2'; + } + } + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private seriesService: SeriesService, private readerService: ReaderService, private location: Location, - private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, + private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService, private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService, @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) { this.navService.hideNavBar(); + this.themeService.clearThemes(); this.navService.hideSideNav(); - - this.darkModeStyleElem = this.renderer.createElement('style'); - this.darkModeStyleElem.innerHTML = this.darkModeStyles; - this.fontFamilies = this.bookService.getFontFamilies(); - - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - this.user = user; - - if (this.user.preferences.bookReaderFontFamily === undefined) { - this.user.preferences.bookReaderFontFamily = 'default'; - } - if (this.user.preferences.bookReaderFontSize === undefined) { - this.user.preferences.bookReaderFontSize = 100; - } - if (this.user.preferences.bookReaderLineSpacing === undefined) { - this.user.preferences.bookReaderLineSpacing = 100; - } - if (this.user.preferences.bookReaderMargin === undefined) { - this.user.preferences.bookReaderMargin = 0; - } - if (this.user.preferences.bookReaderReadingDirection === undefined) { - this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight; - } - - this.readingDirection = this.user.preferences.bookReaderReadingDirection; - - this.clickToPaginate = this.user.preferences.bookReaderTapToPaginate; - - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(user.preferences.bookReaderFontFamily, [])); - - this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => { - this.updateFontFamily(changes); - }); - } - - const bodyNode = this.document.querySelector('body'); - if (bodyNode !== undefined && bodyNode !== null) { - this.originalBodyColor = bodyNode.style.background; - } - this.resetSettings(); - }); } /** - * After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the - * table of content) then we calculate what has already been reached and grab the last reached one to save progress. If page anchors aren't setup (toc missing), then try to save progress + * After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the + * table of content) then we calculate what has already been reached and grab the last reached one to save progress. If page anchors aren't setup (toc missing), then try to save progress * based on the last seen scroll part (xpath). */ ngAfterViewInit() { // check scroll offset and if offset is after any of the "id" markers, save progress fromEvent(this.reader.nativeElement, 'scroll') - .pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => { + .pipe( + debounceTime(200), + takeUntil(this.onDestroy)) + .subscribe((event) => { if (this.isLoading) return; - // Highlight the current chapter we are on - if (Object.keys(this.pageAnchors).length !== 0) { - // get the height of the document so we can capture markers that are halfway on the document viewport - const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); - - const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); - if (alreadyReached.length > 0) { - this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; - } else { - this.currentPageAnchor = ''; - } - } - - - // Find the element that is on screen to bookmark against - const intersectingEntries = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) - .filter(element => !element.classList.contains('no-observe')) - .filter(entry => { - return this.utilityService.isInViewport(entry, this.topOffset); - }); - - intersectingEntries.sort((a: Element, b: Element) => { - const aTop = a.getBoundingClientRect().top; - const bTop = b.getBoundingClientRect().top; - if (aTop < bTop) { - return -1; - } - if (aTop > bTop) { - return 1; - } - - return 0; - }); - - if (intersectingEntries.length > 0) { - let path = this.getXPathTo(intersectingEntries[0]); - if (path === '') { return; } - if (!path.startsWith('id')) { - path = '//html[1]/' + path; - } - this.lastSeenScrollPartPath = path; - } - - if (this.lastSeenScrollPartPath !== '') { - this.saveProgress(); - } + this.handleScrollEvent(); }); } + handleScrollEvent() { + // Highlight the current chapter we are on + if (Object.keys(this.pageAnchors).length !== 0) { + // get the height of the document so we can capture markers that are halfway on the document viewport + const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); + + const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); + if (alreadyReached.length > 0) { + this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; + } else { + this.currentPageAnchor = ''; + } + } + + + // Find the element that is on screen to bookmark against + + const intersectingEntries = Array.from(this.readingHtml.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) + .filter(element => !element.classList.contains('no-observe')) + .filter(entry => { + return this.utilityService.isInViewport(entry, this.topOffset); + }); + + intersectingEntries.sort(this.sortElements); + + if (intersectingEntries.length > 0) { + let path = this.getXPathTo(intersectingEntries[0]); + if (path === '') { return; } + if (!path.startsWith('id')) { + path = '//html[1]/' + path; + } + this.lastSeenScrollPartPath = path; + } + + if (this.lastSeenScrollPartPath !== '') { + this.saveProgress(); + } + } + saveProgress() { let tempPageNum = this.pageNum; if (this.pageNum == this.maxPages - 1) { @@ -381,28 +378,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { - const bodyNode = this.document.querySelector('body'); - if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) { - bodyNode.style.background = this.originalBodyColor; - this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => { - this.themeService.setTheme(theme.name); - }); - } + this.clearTimeout(this.clickToPaginateVisualOverlayTimeout); + this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); + + this.themeService.clearBookTheme(); + + this.themeService.currentTheme$.pipe(take(1)).subscribe(theme => { + this.themeService.setTheme(theme.name); + }); + this.navService.showNavBar(); this.navService.showSideNav(); - - const head = this.document.querySelector('head'); - this.renderer.removeChild(head, this.darkModeStyleElem); - - if (this.clickToPaginateVisualOverlayTimeout !== undefined) { - clearTimeout(this.clickToPaginateVisualOverlayTimeout); - this.clickToPaginateVisualOverlayTimeout = undefined; - } - if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) { - clearTimeout(this.clickToPaginateVisualOverlayTimeout2); - this.clickToPaginateVisualOverlayTimeout2 = undefined; - } - this.readerService.exitFullscreen(); this.onDestroy.next(); @@ -439,7 +425,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } }); - this.init(); + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.user = user; + this.init(); + } + }); } init() { @@ -449,11 +440,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.prevChapterDisabled = false; this.nextChapterPrefetched = false; + + this.bookService.getBookInfo(this.chapterId).subscribe(info => { this.bookTitle = info.bookTitle; - + if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { - // Redirect to the manga reader. + // Redirect to the manga reader. const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); this.router.navigate(['library', info.libraryId, 'series', info.seriesId, 'manga', this.chapterId], {queryParams: params}); return; @@ -463,27 +456,31 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { chapter: this.seriesService.getChapter(this.chapterId), progress: this.readerService.getProgress(this.chapterId), chapters: this.bookService.getBookChapters(this.chapterId), - }).pipe(take(1)).subscribe(results => { + }).subscribe(results => { this.chapter = results.chapter; this.volumeId = results.chapter.volumeId; this.maxPages = results.chapter.pages; this.chapters = results.chapters; this.pageNum = results.progress.pageNum; - - + if (results.progress.bookScrollId) this.lastSeenScrollPartPath = results.progress.bookScrollId; + + + this.continuousChaptersStack.push(this.chapterId); this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => { this.libraryType = type; }); - - - + + this.updateLayoutMode(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default); + + + if (this.pageNum >= this.maxPages) { this.pageNum = this.maxPages - 1; this.saveProgress(); } - + this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).pipe(take(1)).subscribe(chapterId => { this.nextChapterId = chapterId; if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) { @@ -496,7 +493,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.prevChapterDisabled = true; } }); - + // Check if user progress has part, if so load it so we scroll to it this.loadPage(results.progress.bookScrollId || undefined); }, () => { @@ -505,8 +502,18 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }, 200); }); }); + } - + @HostListener('window:resize', ['$event']) + onResize(event: any){ + // Update the window Height + this.windowHeight = Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight); + } + + @HostListener('window:orientationchange', ['$event']) + onOrientationChange() { + // Update the window Height + this.windowHeight = Math.max(this.readingSectionElemRef.nativeElement.clientHeight, window.innerHeight); } @HostListener('window:keydown', ['$event']) @@ -520,7 +527,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else if (event.key === KEY_CODES.SPACE) { this.toggleDrawer(); event.stopPropagation(); - event.preventDefault(); + event.preventDefault(); } else if (event.key === KEY_CODES.G) { this.goToPage(); } else if (event.key === KEY_CODES.F) { @@ -580,7 +587,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { loadChapter(chapterId: number, direction: 'Next' | 'Prev') { if (chapterId >= 0) { this.chapterId = chapterId; - this.continuousChaptersStack.push(chapterId); + this.continuousChaptersStack.push(chapterId); // Load chapter Id onto route but don't reload const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); @@ -598,9 +605,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - loadChapterPage(pageNum: number, part: string) { - this.setPageNum(pageNum); - this.loadPage('id("' + part + '")'); + loadChapterPage(event: {pageNum: number, part: string}) { + this.setPageNum(event.pageNum); + this.loadPage('id("' + event.part + '")'); } closeReader() { @@ -611,34 +618,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - resetSettings() { - const windowWidth = window.innerWidth - || this.document.documentElement.clientWidth - || this.document.body.clientWidth; - - let margin = '15%'; - if (windowWidth <= 700) { - margin = '5%'; - } - if (this.user) { - if (windowWidth > 700) { - margin = this.user.preferences.bookReaderMargin + '%'; - } - this.pageStyles = {'font-family': this.user.preferences.bookReaderFontFamily, 'font-size': this.user.preferences.bookReaderFontSize + '%', 'margin-left': margin, 'margin-right': margin, 'line-height': this.user.preferences.bookReaderLineSpacing + '%'}; - - this.toggleDarkMode(this.user.preferences.bookReaderDarkMode); - } else { - this.pageStyles = {'font-family': 'default', 'font-size': '100%', 'margin-left': margin, 'margin-right': margin, 'line-height': '100%'}; - this.toggleDarkMode(false); - } - - this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); - this.updateReaderStyles(); - } /** - * Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value - * from 'kavita-part', which will cause the reader to scroll to the marker. + * Adds a click handler for any anchors that have 'kavita-page'. If 'kavita-page' present, changes page to kavita-page and optionally passes a part value + * from 'kavita-part', which will cause the reader to scroll to the marker. */ addLinkClickHandlers() { var links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); @@ -649,13 +632,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.adhocPageHistory.peek()?.page !== this.pageNum) { this.adhocPageHistory.push({page: this.pageNum, scrollOffset: window.pageYOffset}); } - + var partValue = e.target.attributes.hasOwnProperty('kavita-part') ? e.target.attributes['kavita-part'].value : undefined; if (partValue && page === this.pageNum) { this.scrollTo(e.target.attributes['kavita-part'].value); return; } - + this.setPageNum(page); this.loadPage(partValue); }); @@ -669,6 +652,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } + promptForPage() { const question = 'There are ' + (this.maxPages - 1) + ' pages. What page do you want to go to?'; const goToPageNum = window.prompt(question, ''); @@ -698,9 +682,358 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pageNum = page; this.loadPage(); + } + + + + + loadPage(part?: string | undefined, scrollTop?: number | undefined) { + this.isLoading = true; + + this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { + this.page = this.domSanitizer.bypassSecurityTrustHtml(content); // PERF: Potential optimization to prefetch next/prev page and store in localStorage + + setTimeout(() => { + this.addLinkClickHandlers(); + this.updateReaderStyles(this.pageStyles); + this.updateReaderStyles(this.pageStyles); + + const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); + if (imgs === null || imgs.length === 0) { + this.setupPage(part, scrollTop); + return; + } + + Promise.all(Array.from(imgs) + .filter(img => !img.complete) + .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) + .then(() => { + this.setupPage(part, scrollTop); + this.updateImagesWithHeight(); + }); + }, 10); + }); + } + + /** + * Applies a max-height inline css property on each image in the page if the layout mode is column-based, else it removes the property + */ + updateImagesWithHeight() { + const images = this.readingSectionElemRef.nativeElement.querySelectorAll('img') || []; + + if (this.layoutMode !== BookPageLayoutMode.Default) { + const height = this.ColumnHeight; + Array.from(images).forEach(img => { + this.renderer.setStyle(img, 'max-height', height); + }); + } else { + Array.from(images).forEach(img => { + this.renderer.removeStyle(img, 'max-height'); + }); + } + } + + setupPage(part?: string | undefined, scrollTop?: number | undefined) { + this.isLoading = false; + this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.readingSectionElemRef.nativeElement.clientHeight; + + // Virtual Paging stuff + this.windowWidth = window.innerWidth + || this.document.documentElement.clientWidth + || this.document.body.clientWidth; + + this.windowHeight = Math.max(this.readingSectionElemRef.nativeElement.clientHeight, this.windowHeight); + this.updateLayoutMode(this.layoutMode || BookPageLayoutMode.Default); + + // Find all the part ids and their top offset + this.setupPageAnchors(); + + + if (part !== undefined && part !== '') { + this.scrollTo(part); + } else if (scrollTop !== undefined && scrollTop !== 0) { + this.scrollService.scrollTo(scrollTop, this.reader.nativeElement); + } else { + + if (this.layoutMode === BookPageLayoutMode.Default) { + this.scrollService.scrollTo(0, this.reader.nativeElement); + } else { + this.reader.nativeElement.children + // We need to check if we are paging back, because we need to adjust the scroll + if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { + this.scrollService.scrollToX(this.readingHtml.nativeElement.scrollWidth, this.readingHtml.nativeElement); + } else { + this.scrollService.scrollToX(0, this.readingHtml.nativeElement); + } + } + } + + // we need to click the document before arrow keys will scroll down. + this.reader.nativeElement.focus(); + this.saveProgress(); + } + + + goBack() { + if (!this.adhocPageHistory.isEmpty()) { + const page = this.adhocPageHistory.pop(); + if (page !== undefined) { + this.setPageNum(page.page); + this.loadPage(undefined, page.scrollOffset); + } + } + } + + setPageNum(pageNum: number) { + this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); + + if (this.pageNum >= this.maxPages - 10) { + // Tell server to cache the next chapter + if (this.nextChapterId > 0 && !this.nextChapterPrefetched) { + this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1)).subscribe(res => { + this.nextChapterPrefetched = true; + }); + } + } else if (this.pageNum <= 10) { + if (this.prevChapterId > 0 && !this.prevChapterPrefetched) { + this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1)).subscribe(res => { + this.prevChapterPrefetched = true; + }); + } + } + } + + prevPage() { + const oldPageNum = this.pageNum; + + this.pagingDirection = PAGING_DIRECTION.BACKWARDS; + + // We need to handle virtual paging before we increment the actual page + if (this.layoutMode !== BookPageLayoutMode.Default) { + + const scrollOffset = this.readingHtml.nativeElement.scrollLeft; + const pageWidth = this.readingSectionElemRef.nativeElement.clientWidth - (this.readingSectionElemRef.nativeElement.clientWidth*(parseInt(this.pageStyles['margin-left'], 10) / 100))*2 + 20; + + if (scrollOffset - pageWidth >= 0) { + this.scrollService.scrollToX(scrollOffset - pageWidth, this.readingHtml.nativeElement); + this.saveProgress(); + return; + } + } + + if (this.readingDirection === ReadingDirection.LeftToRight) { + this.setPageNum(this.pageNum - 1); + } else { + this.setPageNum(this.pageNum + 1); + } + + if (oldPageNum === 0) { + // Move to next volume/chapter automatically + this.loadPrevChapter(); + return; + } + + if (oldPageNum === this.pageNum) { return; } + + // If prev and in default layout, need to handle somehow + + this.loadPage(); + } + + nextPage(event?: any) { + if (event) { + event.stopPropagation(); + event.preventDefault(); + } + + this.pagingDirection = PAGING_DIRECTION.FORWARD; + + // We need to handle virtual paging before we increment the actual page + if (this.layoutMode !== BookPageLayoutMode.Default) { + + const scrollOffset = this.readingHtml.nativeElement.scrollLeft; + const totalScroll = this.readingHtml.nativeElement.scrollWidth; + const pageWidth = this.readingSectionElemRef.nativeElement.clientWidth - (this.readingSectionElemRef.nativeElement.clientWidth*(parseInt(this.pageStyles['margin-left'], 10) / 100))*2 + 20; + + + if (scrollOffset + pageWidth < totalScroll) { + this.scrollService.scrollToX(scrollOffset + pageWidth, this.readingHtml.nativeElement); + this.handleScrollEvent(); + this.saveProgress(); + return; + } + } + + const oldPageNum = this.pageNum; + if (oldPageNum + 1 === this.maxPages) { + // Move to next volume/chapter automatically + this.loadNextChapter(); + return; + } + + + if (this.readingDirection === ReadingDirection.LeftToRight) { + this.setPageNum(this.pageNum + 1); + } else { + this.setPageNum(this.pageNum - 1); + } + + if (oldPageNum === this.pageNum) { return; } + + this.loadPage(); + } + + /** + * Applies styles onto the html of the book page + */ + updateReaderStyles(pageStyles: PageStyle) { + this.pageStyles = pageStyles; + if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return; + + // Line Height must be placed on each element in the page + + // Apply page level overrides + Object.entries(this.pageStyles).forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); + return; + } + if (pageLevelStyles.includes(item[0])) { + this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); + } + }); + + const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); + for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { + const elem = this.readingHtml.nativeElement.children.item(i); + if (elem?.tagName === 'STYLE') continue; + individualElementStyles.forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(elem, item[0]); + return; + } + this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); + }); + + } } + setOverrideStyles(theme: BookTheme) { + // TODO: Put optimization in to avoid any work if the theme is the same as selected (or have reading settings control handle that) + + // Remove all themes + Array.from(this.document.querySelectorAll('style[id^="brtheme-"]')).forEach(elem => elem.remove()); + + this.darkMode = theme.isDarkTheme; + + const styleElem = this.renderer.createElement('style'); + styleElem.id = theme.selector; + styleElem.innerHTML = theme.content; + + + this.renderer.appendChild(this.document.querySelector('.reading-section'), styleElem); + // I need to also apply the selector onto the body so that any css variables will take effect + this.themeService.setBookTheme(theme.selector); + } + + toggleDrawer() { + this.drawerOpen = !this.drawerOpen; + } + + scrollTo(partSelector: string) { + if (partSelector.startsWith('#')) { + partSelector = partSelector.substr(1, partSelector.length); + } + + let element: Element | null = null; + if (partSelector.startsWith('//') || partSelector.startsWith('id(')) { + // Part selector is a XPATH + element = this.getElementFromXPath(partSelector); + } else { + element = this.document.querySelector('*[id="' + partSelector + '"]'); + } + + if (element === null) return; + + if (this.layoutMode === BookPageLayoutMode.Default) { + const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; + // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point + setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); + } else { + setTimeout(() => (element as Element).scrollIntoView({'block': 'start', 'inline': 'start'})); + } + } + + + getElementFromXPath(path: string) { + const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + if (node?.nodeType === Node.ELEMENT_NODE) { + return node as Element; + } + return null; + } + + getXPathTo(element: any): string { + if (element === null) return ''; + if (element.id !== '') { return 'id("' + element.id + '")'; } + if (element === this.document.body) { return element.tagName; } + + + let ix = 0; + const siblings = element.parentNode?.childNodes || []; + for (let sibling of siblings) { + if (sibling === element) { + return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; + } + if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { + ix++; + } + + } + return ''; + } + + /** + * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state + */ + turnOffIncognito() { + this.incognitoMode = false; + const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); + window.history.replaceState({}, '', newRoute); + this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); + this.saveProgress(); + } + + toggleFullscreen() { + this.isFullscreen = this.readerService.checkFullscreenMode(); + if (this.isFullscreen) { + this.readerService.exitFullscreen(() => { + this.isFullscreen = false; + this.renderer.removeStyle(this.reader.nativeElement, 'background'); + }); + } else { + this.readerService.enterFullscreen(this.reader.nativeElement, () => { + this.isFullscreen = true; + // HACK: This is a bug with how browsers change the background color for fullscreen mode + this.renderer.setStyle(this.reader.nativeElement, 'background', this.themeService.getCssVariable('--bs-body-color')); + if (!this.darkMode) { + this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); + } + }); + } + } + + updateLayoutMode(mode: BookPageLayoutMode) { + this.layoutMode = mode; + + // Remove any max-heights from column layout + this.updateImagesWithHeight(); + } + + // Table of Contents cleanIdSelector(id: string) { const tokens = id.split('/'); if (tokens.length > 0) { @@ -730,308 +1063,29 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - loadPage(part?: string | undefined, scrollTop?: number | undefined) { - this.isLoading = true; + // Settings Handlers + showPaginationOverlay(clickToPaginate: boolean) { + this.clickToPaginate = clickToPaginate; - this.saveProgress(); - - this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => { - this.page = this.domSanitizer.bypassSecurityTrustHtml(content); - setTimeout(() => { - this.addLinkClickHandlers(); - this.updateReaderStyles(); - // We need to get the offset after we ensure the title has rendered - requestAnimationFrame(() => this.topOffset = this.stickyTopElemRef.nativeElement?.getBoundingClientRect().height); - - const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); - if (imgs === null || imgs.length === 0) { - this.setupPage(part, scrollTop); - return; - } - - // Apply scaling class to all images to ensure they scale down to max width to not blow out the reader - Array.from(imgs).forEach(img => this.renderer.addClass(img, 'scale-width')); - - Promise.all(Array.from(imgs) - .filter(img => !img.complete) - .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) - .then(() => { - this.setupPage(part, scrollTop); - }); - }, 10); - }); - } - - setupPage(part?: string | undefined, scrollTop?: number | undefined) { - this.isLoading = false; - this.scrollbarNeeded = this.readingHtml.nativeElement.clientHeight > this.readingSectionElemRef.nativeElement.clientHeight; - - // Find all the part ids and their top offset - this.setupPageAnchors(); - - - if (part !== undefined && part !== '') { - this.scrollTo(part); - } else if (scrollTop !== undefined && scrollTop !== 0) { - this.scrollService.scrollTo(scrollTop, this.reader.nativeElement); - } else { - this.scrollService.scrollTo(0, this.reader.nativeElement); - } - - // we need to click the document before arrow keys will scroll down. - this.reader.nativeElement.focus(); - } - - setPageNum(pageNum: number) { - this.pageNum = Math.max(Math.min(pageNum, this.maxPages), 0); - } - - goBack() { - if (!this.adhocPageHistory.isEmpty()) { - const page = this.adhocPageHistory.pop(); - if (page !== undefined) { - this.setPageNum(page.page); - this.loadPage(undefined, page.scrollOffset); - } - } - } - - clickOverlayClass(side: 'right' | 'left') { - if (!this.clickToPaginateVisualOverlay) { - return ''; - } - - if (this.readingDirection === ReadingDirection.LeftToRight) { - return side === 'right' ? 'highlight' : 'highlight-2'; - } - return side === 'right' ? 'highlight-2' : 'highlight'; - } - - prevPage() { - const oldPageNum = this.pageNum; - - if (this.readingDirection === ReadingDirection.LeftToRight) { - this.setPageNum(this.pageNum - 1); - } else { - this.setPageNum(this.pageNum + 1); - } - - if (oldPageNum === 0) { - // Move to next volume/chapter automatically - this.loadPrevChapter(); - return; - } - - if (oldPageNum === this.pageNum) { return; } - - this.loadPage(); - } - - nextPage(event?: any) { - if (event) { - event.stopPropagation(); - event.preventDefault(); - } - const oldPageNum = this.pageNum; - if (oldPageNum + 1 === this.maxPages) { - // Move to next volume/chapter automatically - this.loadNextChapter(); - return; - } - - - if (this.readingDirection === ReadingDirection.LeftToRight) { - this.setPageNum(this.pageNum + 1); - } else { - this.setPageNum(this.pageNum - 1); - } - - - - if (oldPageNum === this.pageNum) { return; } - - - this.loadPage(); - } - - updateFontSize(amount: number) { - let val = parseInt(this.pageStyles['font-size'].substr(0, this.pageStyles['font-size'].length - 1), 10); - - if (val + amount > 300 || val + amount < 50) { - return; - } - - this.pageStyles['font-size'] = val + amount + '%'; - this.updateReaderStyles(); - } - - updateFontFamily(familyName: string) { - if (familyName === null) familyName = ''; - let cleanedName = familyName.replace(' ', '_').replace('!important', '').trim(); - if (cleanedName === 'default') { - this.pageStyles['font-family'] = 'inherit'; - } else { - this.pageStyles['font-family'] = "'" + cleanedName + "'"; - } - - this.updateReaderStyles(); - } - - updateMargin(amount: number) { - let cleanedValue = this.pageStyles['margin-left'].replace('%', '').replace('!important', '').trim(); - let val = parseInt(cleanedValue, 10); - - if (val + amount > 30 || val + amount < 0) { - return; - } - - this.pageStyles['margin-left'] = (val + amount) + '%'; - this.pageStyles['margin-right'] = (val + amount) + '%'; - - this.updateReaderStyles(); - } - - updateLineSpacing(amount: number) { - const cleanedValue = parseInt(this.pageStyles['line-height'].replace('%', '').replace('!important', '').trim(), 10); - - if (cleanedValue + amount > 250 || cleanedValue + amount < 100) { - return; - } - - this.pageStyles['line-height'] = (cleanedValue + amount) + '%'; - - this.updateReaderStyles(); - } - - /** - * Applies styles onto the html of the book page - */ - updateReaderStyles() { - if (this.readingHtml === undefined || !this.readingHtml.nativeElement) return; - - // Line Height must be placed on each element in the page - - // Apply page level overrides - Object.entries(this.pageStyles).forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); - return; - } - if (pageLevelStyles.includes(item[0])) { - this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); - } - }); - - const individualElementStyles = Object.entries(this.pageStyles).filter(item => elementLevelStyles.includes(item[0])); - for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { - const elem = this.readingHtml.nativeElement.children.item(i); - if (elem?.tagName === 'STYLE') continue; - individualElementStyles.forEach(item => { - if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { - // Remove the style or skip - this.renderer.removeStyle(elem, item[0]); - return; - } - this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); - }); - - } - - } - - - toggleDarkMode(force?: boolean) { - if (force !== undefined) { - this.darkMode = force; - } else { - this.darkMode = !this.darkMode; - } - - this.setOverrideStyles(); - } - - toggleReadingDirection() { - if (this.readingDirection === ReadingDirection.LeftToRight) { - this.readingDirection = ReadingDirection.RightToLeft; - } else { - this.readingDirection = ReadingDirection.LeftToRight; - } - } - - getDarkModeBackgroundColor() { - return this.darkMode ? '#292929' : '#fff'; - } - - setOverrideStyles() { - const bodyNode = this.document.querySelector('body'); - if (bodyNode !== undefined && bodyNode !== null) { - if (this.themeService.isDarkTheme()) { - bodyNode.classList.remove('bg-dark'); - } - - bodyNode.style.background = this.getDarkModeBackgroundColor(); - } - this.backgroundColor = this.getDarkModeBackgroundColor(); - const head = this.document.querySelector('head'); - if (this.darkMode) { - this.renderer.appendChild(head, this.darkModeStyleElem) - } else { - this.renderer.removeChild(head, this.darkModeStyleElem); - } - } - - toggleDrawer() { - this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight; - this.drawerOpen = !this.drawerOpen; - } - - closeDrawer() { - this.drawerOpen = false; - } - - handleReaderClick(event: MouseEvent) { - if (this.drawerOpen) { - this.closeDrawer(); - event.stopPropagation(); - event.preventDefault(); - } - } - - - scrollTo(partSelector: string) { - if (partSelector.startsWith('#')) { - partSelector = partSelector.substr(1, partSelector.length); - } - - let element = null; - if (partSelector.startsWith('//') || partSelector.startsWith('id(')) { - // Part selector is a XPATH - element = this.getElementFromXPath(partSelector); - } else { - element = this.document.querySelector('*[id="' + partSelector + '"]'); - } - - if (element === null) return; - const fromTopOffset = element.getBoundingClientRect().top + window.pageYOffset + TOP_OFFSET; - // We need to use a delay as webkit browsers (aka apple devices) don't always have the document rendered by this point - setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), 10); - } - - toggleClickToPaginate() { - this.clickToPaginate = !this.clickToPaginate; - - if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) { - clearTimeout(this.clickToPaginateVisualOverlayTimeout2); - this.clickToPaginateVisualOverlayTimeout2 = undefined; - } - if (!this.clickToPaginate) { return; } + // if (this.clickToPaginateVisualOverlayTimeout2 !== undefined) { + // clearTimeout(this.clickToPaginateVisualOverlayTimeout2); + // this.clickToPaginateVisualOverlayTimeout2 = undefined; + // } + this.clearTimeout(this.clickToPaginateVisualOverlayTimeout2); + if (!clickToPaginate) { return; } this.clickToPaginateVisualOverlayTimeout2 = setTimeout(() => { this.showClickToPaginateVisualOverlay(); }, 200); } + clearTimeout(timeoutId: number | undefined) { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + } + showClickToPaginateVisualOverlay() { this.clickToPaginateVisualOverlay = true; @@ -1045,60 +1099,20 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - getElementFromXPath(path: string) { - const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; - if (node?.nodeType === Node.ELEMENT_NODE) { - return node as Element; - } - return null; - } - - getXPathTo(element: any): string { - if (element === null) return ''; - if (element.id !== '') { return 'id("' + element.id + '")'; } - if (element === this.document.body) { return element.tagName; } - - - let ix = 0; - const siblings = element.parentNode?.childNodes || []; - for (let sibling of siblings) { - if (sibling === element) { - return this.getXPathTo(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']'; - } - if (sibling.nodeType === 1 && sibling.tagName === element.tagName) { - ix++; - } - - } - return ''; - } - /** - * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state + * Responsible for returning the class to show an overlay or not + * @param side + * @returns */ - turnOffIncognito() { - this.incognitoMode = false; - const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); - window.history.replaceState({}, '', newRoute); - this.toastr.info('Incognito mode is off. Progress will now start being tracked.'); - this.saveProgress(); - } - - toggleFullscreen() { - this.isFullscreen = this.readerService.checkFullscreenMode(); - if (this.isFullscreen) { - this.readerService.exitFullscreen(() => { - this.isFullscreen = false; - this.renderer.removeStyle(this.reader.nativeElement, 'background'); - }); - } else { - this.readerService.enterFullscreen(this.reader.nativeElement, () => { - this.isFullscreen = true; - // HACK: This is a bug with how browsers change the background color for fullscreen mode - if (!this.darkMode) { - this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); - } - }); + clickOverlayClass(side: 'right' | 'left') { + // TODO: See if we can use RXjs or a component to manage this + if (!this.clickToPaginateVisualOverlay) { + return ''; } + + if (this.readingDirection === ReadingDirection.LeftToRight) { + return side === 'right' ? 'highlight' : 'highlight-2'; + } + return side === 'right' ? 'highlight-2' : 'highlight'; } } diff --git a/UI/Web/src/app/book-reader/book.service.ts b/UI/Web/src/app/book-reader/book.service.ts index bef4ce73d..10d5b6f7c 100644 --- a/UI/Web/src/app/book-reader/book.service.ts +++ b/UI/Web/src/app/book-reader/book.service.ts @@ -10,6 +10,16 @@ export interface BookPage { html: string; } +export interface FontFamily { + /** + * What the user should see + */ + title: string; + /** + * The actual font face + */ + family: string; +} @Injectable({ providedIn: 'root' @@ -20,8 +30,10 @@ export class BookService { constructor(private http: HttpClient) { } - getFontFamilies() { - return ['default', 'EBGaramond', 'Fira Sans', 'Lato', 'Libre Baskerville', 'Merriweather', 'Nanum Gothic', 'RocknRoll One']; + getFontFamilies(): Array { + return [{title: 'default', family: 'default'}, {title: 'EBGaramond', family: 'EBGaramond'}, {title: 'Fira Sans', family: 'Fira_Sans'}, + {title: 'Lato', family: 'Lato'}, {title: 'Libre Baskerville', family: 'Libre_Baskerville'}, {title: 'Merriweather', family: 'Merriweather'}, + {title: 'Nanum Gothic', family: 'Nanum_Gothic'}, {title: 'RocknRoll One', family: 'RocknRoll_One'}, {title: 'Open Dyslexic', family: 'OpenDyslexic2'}]; } getBookChapters(chapterId: number) { diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.html b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.html new file mode 100644 index 000000000..e6c1c23bd --- /dev/null +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.html @@ -0,0 +1,133 @@ + +
+ + + +

+ +

+
+ +
+
+
+ + +
+
+
+ + + + + + +
+ +
+ + + 1x + + 2.5x + +
+ +
+ + + + + + +
+ +
+ +
+
+
+ +
+ + + +

+ +

+
+ +
+ + +
+
+ +
Click the edges of the screen to paginate
+
+ + +
+
+
+ + Put reader in fullscreen mode + + + + +
+ +
+ +
+
+ + + + + + + + +
+
+ + +
+
+ + + +

+ +

+
+ +
+ + + +
+
+
+ +
+
\ No newline at end of file diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.scss b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.scss new file mode 100644 index 000000000..d9348c704 --- /dev/null +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.scss @@ -0,0 +1,9 @@ +.dot { + height: 25px; + width: 25px; + border-radius: 50%; +} + +.active { + border: 1px solid var(--primary-color); +} \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts new file mode 100644 index 000000000..e3ec33ac4 --- /dev/null +++ b/UI/Web/src/app/book-reader/reader-settings/reader-settings.component.ts @@ -0,0 +1,272 @@ +import { DOCUMENT } from '@angular/common'; +import { Component, EventEmitter, Inject, OnDestroy, OnInit, Output } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { Subject, take, takeUntil } from 'rxjs'; +import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode'; +import { BookTheme } from 'src/app/_models/preferences/book-theme'; +import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; +import { ThemeProvider } from 'src/app/_models/preferences/site-theme'; +import { User } from 'src/app/_models/user'; +import { AccountService } from 'src/app/_services/account.service'; +import { ThemeService } from 'src/app/_services/theme.service'; +import { BookService, FontFamily } from '../book.service'; +import { BookBlackTheme } from '../_models/book-black-theme'; +import { BookDarkTheme } from '../_models/book-dark-theme'; +import { BookWhiteTheme } from '../_models/book-white-theme'; + +/** + * Used for book reader. Do not use for other components + */ +export interface PageStyle { + 'font-family': string; + 'font-size': string; + 'line-height': string; + 'margin-left': string; + 'margin-right': string; +} + +export const bookColorThemes = [ + { + name: 'Dark', + colorHash: '#292929', + isDarkTheme: true, + isDefault: true, + provider: ThemeProvider.System, + selector: 'brtheme-dark', + content: BookDarkTheme + }, + { + name: 'Black', + colorHash: '#000000', + isDarkTheme: true, + isDefault: false, + provider: ThemeProvider.System, + selector: 'brtheme-black', + content: BookBlackTheme + }, + { + name: 'White', + colorHash: '#FFFFFF', + isDarkTheme: false, + isDefault: false, + provider: ThemeProvider.System, + selector: 'brtheme-white', + content: BookWhiteTheme + }, +]; + +const mobileBreakpointMarginOverride = 700; + +@Component({ + selector: 'app-reader-settings', + templateUrl: './reader-settings.component.html', + styleUrls: ['./reader-settings.component.scss'] +}) +export class ReaderSettingsComponent implements OnInit, OnDestroy { + + /** + * Outputs when clickToPaginate is changed + */ + @Output() clickToPaginateChanged: EventEmitter = new EventEmitter(); + /** + * Outputs when a style is updated and the reader needs to render it + */ + @Output() styleUpdate: EventEmitter = new EventEmitter(); + /** + * Outputs when a theme/dark mode is updated + */ + @Output() colorThemeUpdate: EventEmitter = new EventEmitter(); + /** + * Outputs when a layout mode is updated + */ + @Output() layoutModeUpdate: EventEmitter = new EventEmitter(); + /** + * Outputs when fullscreen is toggled + */ + @Output() fullscreen: EventEmitter = new EventEmitter(); + /** + * Outputs when reading direction is changed + */ + @Output() readingDirection: EventEmitter = new EventEmitter(); + + user!: User; + /** + * List of all font families user can select from + */ + fontOptions: Array = []; + fontFamilies: Array = []; + /** + * Internal property used to capture all the different css properties to render on all elements + */ + pageStyles!: PageStyle; + + readingDirectionModel: ReadingDirection = ReadingDirection.LeftToRight; + + activeTheme: BookTheme | undefined; + + isFullscreen: boolean = false; + + settingsForm: FormGroup = new FormGroup({}); + + /** + * System provided themes + */ + themes: Array = bookColorThemes; + + + private onDestroy: Subject = new Subject(); + + + get BookPageLayoutMode(): typeof BookPageLayoutMode { + return BookPageLayoutMode; + } + + get ReadingDirection() { + return ReadingDirection; + } + + + + constructor(private bookService: BookService, private accountService: AccountService, @Inject(DOCUMENT) private document: Document, private themeService: ThemeService) {} + + ngOnInit(): void { + + this.fontFamilies = this.bookService.getFontFamilies(); + this.fontOptions = this.fontFamilies.map(f => f.title); + + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + if (user) { + this.user = user; + + if (this.user.preferences.bookReaderFontFamily === undefined) { + this.user.preferences.bookReaderFontFamily = 'default'; + } + if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) { + this.user.preferences.bookReaderFontSize = 100; + } + if (this.user.preferences.bookReaderLineSpacing === undefined || this.user.preferences.bookReaderLineSpacing < 100) { + this.user.preferences.bookReaderLineSpacing = 100; + } + if (this.user.preferences.bookReaderMargin === undefined) { + this.user.preferences.bookReaderMargin = 0; + } + if (this.user.preferences.bookReaderReadingDirection === undefined) { + this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight; + } + + + this.readingDirectionModel = this.user.preferences.bookReaderReadingDirection; + + this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, [])); + this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(fontName => { + const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family; + if (familyName === 'default') { + this.pageStyles['font-family'] = 'inherit'; + } else { + this.pageStyles['font-family'] = "'" + familyName + "'"; + } + + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, [])); + this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.pageStyles['font-size'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, [])); + this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.clickToPaginateChanged.emit(value); + }); + + + this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, [])); + this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.pageStyles['line-height'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, [])); + this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(value => { + this.pageStyles['margin-left'] = value + '%'; + this.pageStyles['margin-right'] = value + '%'; + this.styleUpdate.emit(this.pageStyles); + }); + + this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, [])); + this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((layoutMode: BookPageLayoutMode) => { + console.log(layoutMode); + this.layoutModeUpdate.emit(layoutMode); + }); + + this.setTheme(this.user.preferences.bookReaderThemeName || this.themeService.defaultBookTheme); + this.resetSettings(); + } else { + this.resetSettings(); + } + + + }); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + + resetSettings() { + if (this.user) { + this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + '%', this.user.preferences.bookReaderLineSpacing + '%'); + } else { + this.setPageStyles(); + } + + this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); + this.styleUpdate.emit(this.pageStyles); + } + + /** + * Internal method to be used by resetSettings. Pass items in with quantifiers + */ + setPageStyles(fontFamily?: string, fontSize?: string, margin?: string, lineHeight?: string, colorTheme?: string) { + const windowWidth = window.innerWidth + || this.document.documentElement.clientWidth + || this.document.body.clientWidth; + + + let defaultMargin = '15%'; + if (windowWidth <= mobileBreakpointMarginOverride) { + defaultMargin = '5%'; + } + this.pageStyles = { + 'font-family': fontFamily || this.pageStyles['font-family'] || 'default', + 'font-size': fontSize || this.pageStyles['font-size'] || '100%', + 'margin-left': margin || this.pageStyles['margin-left'] || defaultMargin, + 'margin-right': margin || this.pageStyles['margin-right'] || defaultMargin, + 'line-height': lineHeight || this.pageStyles['line-height'] || '100%' + }; + + } + + setTheme(themeName: string) { + const theme = this.themes.find(t => t.name === themeName); + this.activeTheme = theme; + this.colorThemeUpdate.emit(theme); + } + + toggleReadingDirection() { + if (this.readingDirectionModel === ReadingDirection.LeftToRight) { + this.readingDirectionModel = ReadingDirection.RightToLeft; + } else { + this.readingDirectionModel = ReadingDirection.LeftToRight; + } + + this.readingDirection.emit(this.readingDirectionModel); + } + + toggleFullscreen() { + this.fullscreen.emit(); + } +} diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html new file mode 100644 index 000000000..174e8cf8c --- /dev/null +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.html @@ -0,0 +1,25 @@ +
+

Table of Contents

+
+ This book does not have Table of Contents set in the metadata or a toc file +
+
+ +
+ + + +
\ No newline at end of file diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.scss b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.scss new file mode 100644 index 000000000..6ae729a59 --- /dev/null +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.scss @@ -0,0 +1,11 @@ +.table-of-contents li { + cursor: pointer; + + &.active { + font-weight: bold; + } +} + +.chapter-title { + padding-inline-start: 0px +} \ No newline at end of file diff --git a/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts new file mode 100644 index 000000000..101a09191 --- /dev/null +++ b/UI/Web/src/app/book-reader/table-of-contents/table-of-contents.component.ts @@ -0,0 +1,48 @@ +import { AfterViewInit, Component, ElementRef, EventEmitter, Inject, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { Subject } from 'rxjs'; +import { BookChapterItem } from '../_models/book-chapter-item'; + +@Component({ + selector: 'app-table-of-contents', + templateUrl: './table-of-contents.component.html', + styleUrls: ['./table-of-contents.component.scss'] +}) +export class TableOfContentsComponent implements OnInit, OnDestroy { + + @Input() chapterId!: number; + @Input() pageNum!: number; + @Input() currentPageAnchor!: string; + @Input() chapters:Array = []; + + @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); + + + + private onDestroy: Subject = new Subject(); + + + pageAnchors: {[n: string]: number } = {}; + + constructor() {} + + ngOnInit(): void { + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + cleanIdSelector(id: string) { + const tokens = id.split('/'); + if (tokens.length > 0) { + return tokens[0]; + } + return id; + } + + loadChapterPage(pageNum: number, part: string) { + this.loadChapter.emit({pageNum, part}); + } + +} diff --git a/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts b/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts index 7bd6635f8..be3ab498b 100644 --- a/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts +++ b/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts @@ -40,12 +40,12 @@ export class ThemeTestComponent implements OnInit { latestReadDate: '', localizedName: '', originalName: '', - sortName: '', + sortName: '', userRating: 0, - userReview: '', + userReview: '', volumes: [], localizedNameLocked: false, - nameLocked: false, + nameLocked: false, sortNameLocked: false, lastChapterAdded: '', } @@ -62,12 +62,12 @@ export class ThemeTestComponent implements OnInit { latestReadDate: '', localizedName: '', originalName: '', - sortName: '', + sortName: '', userRating: 0, - userReview: '', + userReview: '', volumes: [], localizedNameLocked: false, - nameLocked: false, + nameLocked: false, sortNameLocked: false, lastChapterAdded: '', } diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.html b/UI/Web/src/app/metadata-filter/metadata-filter.component.html index 943858007..340524a60 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.html +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.html @@ -1,21 +1,21 @@ -
-
- + +
+
+ +
-
-
- -
-

Book Settings - -

-
-
- -
-
-
+
+ +
+ Filter +
+
+ +
+
+
+ This is library agnostic diff --git a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts index 212966835..08e9c71c6 100644 --- a/UI/Web/src/app/metadata-filter/metadata-filter.component.ts +++ b/UI/Web/src/app/metadata-filter/metadata-filter.component.ts @@ -2,7 +2,7 @@ import { Component, ContentChild, EventEmitter, Input, OnDestroy, OnInit, Output import { FormControl, FormGroup } from '@angular/forms'; import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap'; import { distinctUntilChanged, forkJoin, map, Observable, of, ReplaySubject, Subject, takeUntil } from 'rxjs'; -import { Breakpoint, UtilityService } from '../shared/_services/utility.service'; +import { UtilityService } from '../shared/_services/utility.service'; import { TypeaheadSettings } from '../typeahead/typeahead-settings'; import { CollectionTag } from '../_models/collection-tag'; import { Genre } from '../_models/genre'; @@ -17,8 +17,8 @@ import { Tag } from '../_models/tag'; import { CollectionTagService } from '../_services/collection-tag.service'; import { LibraryService } from '../_services/library.service'; import { MetadataService } from '../_services/metadata.service'; -import { NavService } from '../_services/nav.service'; import { SeriesService } from '../_services/series.service'; +import { ToggleService } from '../_services/toggle.service'; import { FilterSettings } from './filter-settings'; @Component({ @@ -43,7 +43,6 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { @Output() applyFilter: EventEmitter = new EventEmitter(); @ContentChild('[ngbCollapse]') collapse!: NgbCollapse; - //@ContentChild('commentDrawer') commentDrawer: formatSettings: TypeaheadSettings> = new TypeaheadSettings(); @@ -87,7 +86,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { } constructor(private libraryService: LibraryService, private metadataService: MetadataService, private seriesService: SeriesService, - private utilityService: UtilityService, private collectionTagService: CollectionTagService) { + private utilityService: UtilityService, private collectionTagService: CollectionTagService, public toggleService: ToggleService) { } ngOnInit(): void { @@ -98,6 +97,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { if (this.filterOpen) { this.filterOpen.pipe(takeUntil(this.onDestroy)).subscribe(openState => { this.filteringCollapsed = !openState; + this.toggleService.set(!this.filteringCollapsed); }); } @@ -161,6 +161,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { close() { this.filterOpen.emit(false); this.filteringCollapsed = true; + this.toggleService.set(!this.filteringCollapsed); } ngOnDestroy() { @@ -213,6 +214,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { this.resetTypeaheads.next(false); // Pass false to ensure we reset to the preset and not to an empty typeahead if (this.filterSettings.openByDefault) { this.filteringCollapsed = false; + this.toggleService.set(!this.filteringCollapsed); } this.apply(); }); @@ -598,4 +600,14 @@ export class MetadataFilterComponent implements OnInit, OnDestroy { this.updateApplied++; } + toggleSelected() { + //this.filteringCollapsed = !this.filteringCollapsed; + this.toggleService.toggle(); + } + + setToggle(event: any) { + console.log('set toggle', event); + this.toggleService.set(!this.filteringCollapsed); + } + } diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.scss b/UI/Web/src/app/registration/reset-password/reset-password.component.scss index 9aedb3438..736856043 100644 --- a/UI/Web/src/app/registration/reset-password/reset-password.component.scss +++ b/UI/Web/src/app/registration/reset-password/reset-password.component.scss @@ -4,5 +4,5 @@ .custom-input { background-color: #fff !important; - color: black; + color: black !important; } \ No newline at end of file diff --git a/UI/Web/src/app/registration/user-login/user-login.component.scss b/UI/Web/src/app/registration/user-login/user-login.component.scss index fb13d8579..e37d91cff 100644 --- a/UI/Web/src/app/registration/user-login/user-login.component.scss +++ b/UI/Web/src/app/registration/user-login/user-login.component.scss @@ -12,7 +12,7 @@ a { .custom-input { background-color: #fff !important; - color: black; + color: black !important; } .invalid-feedback { diff --git a/UI/Web/src/app/shared/drawer/drawer.component.html b/UI/Web/src/app/shared/drawer/drawer.component.html index 722ba4e5f..4d7c21972 100644 --- a/UI/Web/src/app/shared/drawer/drawer.component.html +++ b/UI/Web/src/app/shared/drawer/drawer.component.html @@ -1,15 +1,18 @@ -
-
- + + +
diff --git a/UI/Web/src/app/shared/drawer/drawer.component.scss b/UI/Web/src/app/shared/drawer/drawer.component.scss index b8b909772..809ff86d1 100644 --- a/UI/Web/src/app/shared/drawer/drawer.component.scss +++ b/UI/Web/src/app/shared/drawer/drawer.component.scss @@ -1,56 +1,8 @@ -:host { - --drawer-height: 100vh; - --drawer-width: 400px; - --drawer-top-offset: 0px; - //--drawer-background-color: #fff; - } - - .drawer-container { - position: absolute; - top: var(--drawer-top-offset); - right: 0; - width: var(--drawer-width); - height: 100vh; - background: var(--drawer-background-color, #fff); - transition: all 300ms; - box-shadow: 0 6px 4px 2px rgb(0 0 0 / 70%); - padding: 10px 10px; - z-index: 1021; - overflow: auto; - -webkit-overflow-scrolling: touch; +.offcanvas { + color: var(--drawer-text-color); + background-color: var(--drawer-bg-color); +} - &.position-right { - right: calc(-1 * var(--drawer-width)); - &.is-open { - right: 0; - } - } - &.position-left { - left: calc(-1 * var(--drawer-width)); - &.is-open { - left: 0; - } - } - - &.position-bottom { - //top: calc(-1 * var(--drawer-height)); - top: 100vh; - height: 0px; - &.is-open { - //bottom: 0; - top: 100vh; - height: var(--drawer-height); - } - } - - &.position-top { - //bottom: calc(-1 * var(--drawer-height)); - top: 0px; - height: 0px; - &.is-open { - //bottom: 0; - top: 0px; - height: var(--drawer-height); - } - } - } +.hide-if-empty:empty { + display: none !important; +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/drawer/drawer.component.ts b/UI/Web/src/app/shared/drawer/drawer.component.ts index f83e95383..3af6a5db0 100644 --- a/UI/Web/src/app/shared/drawer/drawer.component.ts +++ b/UI/Web/src/app/shared/drawer/drawer.component.ts @@ -14,18 +14,20 @@ export class DrawerOptions { exportAs: "drawer" }) export class DrawerComponent { - @Input() isOpen = false; @Input() width: number = 400; /** * Side of the screen the drawer should animate from */ - @Input() position: 'left' | 'right' | 'bottom' = 'left'; + @Input() position: 'start' | 'end' | 'bottom' | 'top' = 'start'; @Input() options: Partial = new DrawerOptions(); @Output() drawerClosed = new EventEmitter(); + @Output() isOpenChange: EventEmitter = new EventEmitter(); close() { - this.drawerClosed.emit(); + this.isOpen = false; + this.isOpenChange.emit(false); + this.drawerClosed.emit(false); } } diff --git a/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.html b/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.html index fe4bbfa3f..f1d36226f 100644 --- a/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.html +++ b/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.html @@ -7,7 +7,8 @@
- diff --git a/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.ts b/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.ts index 6ded316b4..9d7fa8825 100644 --- a/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.ts +++ b/UI/Web/src/app/sidenav/side-nav-companion-bar/side-nav-companion-bar.component.ts @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angu import { Subject, takeUntil } from 'rxjs'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { NavService } from 'src/app/_services/nav.service'; +import { ToggleService } from 'src/app/_services/toggle.service'; /** * This should go on all pages which have the side nav present and is not Settings related. @@ -39,7 +40,7 @@ export class SideNavCompanionBarComponent implements OnInit, OnDestroy { private onDestroy: Subject = new Subject(); - constructor(private navService: NavService, private utilityService: UtilityService) { + constructor(private navService: NavService, private utilityService: UtilityService, public toggleService: ToggleService) { } ngOnInit(): void { diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html index 3f5e23059..fdaf7a3fb 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.html @@ -9,6 +9,7 @@
+

Site Themes

@@ -20,6 +21,5 @@
-
diff --git a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts index f0a544815..9a2381e89 100644 --- a/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts +++ b/UI/Web/src/app/user-settings/theme-manager/theme-manager.component.ts @@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; import { distinctUntilChanged, Subject, take, takeUntil } from 'rxjs'; import { ThemeService } from 'src/app/_services/theme.service'; +import { BookTheme } from 'src/app/_models/preferences/book-theme'; import { SiteTheme, ThemeProvider } from 'src/app/_models/preferences/site-theme'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; @@ -45,7 +46,7 @@ export class ThemeManagerComponent implements OnInit, OnDestroy { } applyTheme(theme: SiteTheme) { - + if (this.user) { const pref = Object.assign({}, this.user.preferences); pref.theme = theme; @@ -56,7 +57,7 @@ export class ThemeManagerComponent implements OnInit, OnDestroy { this.themeService.setTheme(theme.name); }); } - + } updateDefault(theme: SiteTheme) { diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index cf99950c1..db26de166 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -13,97 +13,87 @@ These are global settings that are bound to your account.

- - - - -

- -

-
- -
-

Image Reader

+ -
-
-   - Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. - Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. - + + + +

+ +

+
+ +
+
+   + Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + +
+ +
+   + How to scale the image to your screen. + How to scale the image to your screen. + +
-
-   - How to scale the image to your screen. - How to scale the image to your screen. - +
+
+   + How to split a full width image (ie both left and right images are combined) + How to split a full width image (ie both left and right images are combined) + +
+
+ + +
-
-
-
-   - How to split a full width image (ie both left and right images are combined) - How to split a full width image (ie both left and right images are combined) - +
+
+   + Render a single image to the screen to two side-by-side images + + +
+
+ + +
-
- - -
-
- -
-
-   - Render a single image to the screen to two side-by-side images - - -
-
- - -
-
-
-
-
- -
+
+
+
-
-
-
- -
+
+
@@ -111,28 +101,32 @@
-
-
-

Book Reader

-
-
- -
-
- - -
-
-
-
+
+ + +
+ + + + + +

+ +

+
+ +
+
  - Should the sides of the book reader screen allow tapping on it to move to prev/next page - Should the sides of the book reader screen allow tapping on it to move to prev/next page + Should the sides of the book reader screen allow tapping on it to move to prev/next page + Should the sides of the book reader screen allow tapping on it to move to prev/next page
@@ -140,7 +134,7 @@
-   +   Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + +
-
-   - How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. - How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. -
+ +
+   + What color theme to apply to the book reader content and menuing + +
+
+
+ + + {{settingsForm.get('bookReaderFontSize')?.value + '%'}} +
+ +
+
+   + How much spacing between the lines of the book + How much spacing between the lines of the book +
+ + {{settingsForm.get('bookReaderLineSpacing')?.value + '%'}} +
+ +
+
+   + How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. + How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. +
+ + + {{settingsForm.get('bookReaderMargin')?.value + '%'}} +
+
- - - - + + + + diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.scss b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.scss index e86d1bd19..84f37233b 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.scss +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.scss @@ -4,4 +4,15 @@ .container { padding-top: 10px; +} + +.form-range { + width: 90%; +} +.range-label { + width: 100%; +} +.range-text { + vertical-align: top; + margin-left: 5px; } \ No newline at end of file diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts index 07ff48b7b..57f351612 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.ts @@ -2,17 +2,22 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; -import { Options } from '@angular-slider/ngx-slider'; import { Title } from '@angular/platform-browser'; import { BookService } from 'src/app/book-reader/book.service'; -import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, layoutModes } from 'src/app/_models/preferences/preferences'; +import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes } from 'src/app/_models/preferences/preferences'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; -import { NavService } from 'src/app/_services/nav.service'; import { ActivatedRoute, Router } from '@angular/router'; import { SettingsService } from 'src/app/admin/settings.service'; +import { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component'; +import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode'; import { forkJoin } from 'rxjs'; +enum AccordionPanelID { + ImageReader = 'image-reader', + BookReader = 'book-reader' +} + @Component({ selector: 'app-user-preferences', templateUrl: './user-preferences.component.html', @@ -25,6 +30,8 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { pageSplitOptions = pageSplitOptions; readingModes = readingModes; layoutModes = layoutModes; + bookLayoutModes = bookLayoutModes; + bookColorThemes = bookColorThemes; settingsForm: FormGroup = new FormGroup({}); passwordChangeForm: FormGroup = new FormGroup({}); @@ -36,22 +43,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { resetPasswordErrors: string[] = []; obserableHandles: Array = []; - - bookReaderLineSpacingOptions: Options = { - floor: 100, - ceil: 250, - step: 10, - }; - bookReaderMarginOptions: Options = { - floor: 0, - ceil: 30, - step: 5, - }; - bookReaderFontSizeOptions: Options = { - floor: 50, - ceil: 300, - step: 10, - }; fontFamilies: Array = []; tabs: Array<{title: string, fragment: string}> = [ @@ -64,12 +55,14 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { opdsEnabled: boolean = false; makeUrl: (val: string) => string = (val: string) => {return this.transformKeyToOpdsUrl(val)}; - backgroundColor: any; // TODO: Hook into user pref + get AccordionPanelID() { + return AccordionPanelID; + } - constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService, - private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService, + constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService, + private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService, private router: Router) { - this.fontFamilies = this.bookService.getFontFamilies(); + this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title); this.route.fragment.subscribe(frag => { const tab = this.tabs.filter(item => item.fragment === frag); @@ -105,7 +98,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { 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, [])); @@ -113,13 +106,14 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { 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('bookReaderDarkMode', new FormControl(this.user.preferences.bookReaderDarkMode, [])); 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('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('theme', new FormControl(this.user.preferences.theme, [])); }); @@ -149,13 +143,14 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.get('readerMode')?.setValue(this.user.preferences.readerMode); this.settingsForm.get('layoutMode')?.setValue(this.user.preferences.layoutMode); this.settingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption); - this.settingsForm.get('bookReaderDarkMode')?.setValue(this.user.preferences.bookReaderDarkMode); this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily); this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize); this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.user.preferences.bookReaderLineSpacing); this.settingsForm.get('bookReaderMargin')?.setValue(this.user.preferences.bookReaderMargin); this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate); this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.user.preferences.bookReaderReadingDirection); + this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode); + this.settingsForm.get('bookReaderThemeName')?.setValue(this.user.preferences.bookReaderThemeName); this.settingsForm.get('theme')?.setValue(this.user.preferences.theme); } @@ -169,23 +164,25 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { if (this.user === undefined) return; const modelSettings = this.settingsForm.value; const data: Preferences = { - readingDirection: parseInt(modelSettings.readingDirection, 10), - scalingOption: parseInt(modelSettings.scalingOption, 10), - pageSplitOption: parseInt(modelSettings.pageSplitOption, 10), - autoCloseMenu: modelSettings.autoCloseMenu, - readerMode: parseInt(modelSettings.readerMode, 10), + readingDirection: parseInt(modelSettings.readingDirection, 10), + scalingOption: parseInt(modelSettings.scalingOption, 10), + pageSplitOption: parseInt(modelSettings.pageSplitOption, 10), + autoCloseMenu: modelSettings.autoCloseMenu, + readerMode: parseInt(modelSettings.readerMode, 10), layoutMode: parseInt(modelSettings.layoutMode, 10), showScreenHints: modelSettings.showScreenHints, - backgroundColor: this.user.preferences.backgroundColor, - bookReaderDarkMode: modelSettings.bookReaderDarkMode, + backgroundColor: modelSettings.backgroundColor, // this.user.preferences.backgroundColor, bookReaderFontFamily: modelSettings.bookReaderFontFamily, bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, bookReaderFontSize: modelSettings.bookReaderFontSize, bookReaderMargin: modelSettings.bookReaderMargin, bookReaderTapToPaginate: modelSettings.bookReaderTapToPaginate, bookReaderReadingDirection: parseInt(modelSettings.bookReaderReadingDirection, 10), + bookReaderLayoutMode: parseInt(modelSettings.bookReaderLayoutMode, 10), + bookReaderThemeName: modelSettings.bookReaderThemeName, theme: modelSettings.theme }; + this.obserableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => { this.toastr.success('Server settings updated'); if (this.user) { diff --git a/UI/Web/src/app/user-settings/user-settings.module.ts b/UI/Web/src/app/user-settings/user-settings.module.ts index 67e8e6ec3..856924289 100644 --- a/UI/Web/src/app/user-settings/user-settings.module.ts +++ b/UI/Web/src/app/user-settings/user-settings.module.ts @@ -3,7 +3,6 @@ import { CommonModule } from '@angular/common'; import { UserPreferencesComponent } from './user-preferences/user-preferences.component'; import { NgbAccordionModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ReactiveFormsModule } from '@angular/forms'; -import { NgxSliderModule } from '@angular-slider/ngx-slider'; import { UserSettingsRoutingModule } from './user-settings-routing.module'; import { ApiKeyComponent } from './api-key/api-key.component'; import { PipeModule } from '../pipe/pipe.module'; @@ -29,7 +28,6 @@ import { SidenavModule } from '../sidenav/sidenav.module'; NgbNavModule, NgbTooltipModule, - NgxSliderModule, ColorPickerModule, // User prefernces background color PipeModule, diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.otf b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.otf new file mode 100644 index 000000000..4c492e2fc Binary files /dev/null and b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Bold.otf differ diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.otf b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.otf new file mode 100644 index 000000000..f71b4303f Binary files /dev/null and b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-BoldItalic.otf differ diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.otf b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.otf new file mode 100644 index 000000000..fdead4d54 Binary files /dev/null and b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Italic.otf differ diff --git a/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf new file mode 100644 index 000000000..1226d2ab2 Binary files /dev/null and b/UI/Web/src/assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.otf differ diff --git a/UI/Web/src/theme/components/_accordion.scss b/UI/Web/src/theme/components/_accordion.scss index fd415d641..8a9226ba7 100644 --- a/UI/Web/src/theme/components/_accordion.scss +++ b/UI/Web/src/theme/components/_accordion.scss @@ -4,12 +4,16 @@ } .accordion-item { - background-color: var(--accordion-body-bg-color); //$dark-card-color; + background-color: var(--accordion-body-bg-color); color: var(--accordion-body-text-color); border-color: var(--accordion-body-border-color); div[role="tabpanel"] { background-color: var(--accordion-header-bg-color); + + .accordion-body { + background-color: var(--accordion-active-body-bg-color); + } } } diff --git a/UI/Web/src/theme/components/_input.scss b/UI/Web/src/theme/components/_input.scss index 7c6f07e58..231be419a 100644 --- a/UI/Web/src/theme/components/_input.scss +++ b/UI/Web/src/theme/components/_input.scss @@ -1,4 +1,4 @@ -input, .form-control { +input:not([type="range"]), .form-control { background-color: var(--input-bg-color); color: var(--input-text-color); border-color: var(--input-border-color); @@ -23,3 +23,11 @@ input, .form-control { color: var(--input-placeholder-color); } } + +.form-range::-webkit-slider-thumb:active { + background-color: var(--input-range-active-color); +} + +.form-range::-webkit-slider-thumb { + background-color: var(--input-range-color); +} diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 6d0cf9ed9..a90459f0e 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -74,7 +74,7 @@ --toast-info-bg-color: #2F96B4; --toast-warning-bg-color: #F89406; - /* Checkboxes */ + /* Checkboxes/Switch */ --checkbox-checked-bg-color: var(--primary-color); --checkbox-border-color: var(--input-focused-border-color); --checkbox-focus-border-color: var(--primary-color); @@ -150,14 +150,15 @@ /* Accordion */ --accordion-header-text-color: rgba(74, 198, 148, 0.9); - --accordion-header-bg-color: rgba(52, 60, 70, 0.5); /* This is a good accent color */ - --accordion-body-bg-color: rgba(22,27,34,0.5); + --accordion-header-bg-color: rgba(52, 60, 70, 0.5); + --accordion-body-bg-color: #292929; --accordion-body-border-color: rgba(239, 239, 239, 0.125); --accordion-body-text-color: var(--body-text-color); --accordion-header-collapsed-text-color: rgba(74, 198, 148, 0.9); - --accordion-header-collapsed-bg-color: rgba(22,27,34,0.5); + --accordion-header-collapsed-bg-color: #292929; --accordion-button-focus-border-color: unset; --accordion-button-focus-box-shadow: unset; + --accordion-active-body-bg-color: #292929; /* Breadcrumb */ --breadcrumb-bg-color: #292d32; @@ -190,6 +191,8 @@ /* Slider */ --slider-text-color: white; + --input-range-color: var(--primary-color); + --input-range-active-color: var(--primary-color-darker-shade); /* Manga Reader */ --manga-reader-overlay-filter: blur(10px); @@ -210,7 +213,9 @@ --carousel-hover-header-text-decoration: none; /** Drawer */ - --drawer-background-color: black; + --drawer-background-color: black; // TODO: Remove this for bg + --drawer-bg-color: #292929; + --drawer-text-color: white; /** Event Widget */ --event-widget-bg-color: rgb(1, 4, 9); diff --git a/UI/Web/src/theme/themes/e-ink.scss b/UI/Web/src/theme/themes/e-ink.scss index 51080dc29..54448f8cb 100644 --- a/UI/Web/src/theme/themes/e-ink.scss +++ b/UI/Web/src/theme/themes/e-ink.scss @@ -2,12 +2,14 @@ --color-scheme: light; --primary-color: black; --primary-color-dark-shade: #3B9E76; + --primary-color-darker-shade: #338A67; + --primary-color-darkest-shade: #25624A; --error-color: #ff4136; - --bs-body-bg: #fff; + --bs-body-bg: #fff; --body-text-color: black; --btn-icon-filter: invert(1) grayscale(100%) brightness(200%); - + /* Navbar */ --navbar-bg-color: black; --navbar-text-color: white; @@ -20,6 +22,8 @@ --input-bg-readonly-color: unset; --input-placeholder-color: #aeaeae; --input-border-color: #ccc; + --input-range-color: var(--primary-color); + --input-range-active-color: var(--primary-color-darker-shade); /* Buttons */ --btn-primary-text-color: black; @@ -50,7 +54,7 @@ --nav-tab-text-color: var(--body-text-color); --nav-tab-bg-color: black; --nav-tab-hover-border-color: black; - + /* Checkboxes */ --checkbox-checked-bg-color: var(--primary-color); --checkbox-bg-color: white; @@ -126,7 +130,7 @@ --dropdown-item-hover-bg-color: var(--primary-color); --dropdown-overlay-color: rgba(0,0,0,0.5); --dropdown-item-bg-color: white; - + /* Manga Reader */ --manga-reader-overlay-filter: none; --manga-reader-overlay-bg-color: rgba(0,0,0,0.5); @@ -134,7 +138,7 @@ --manga-reader-bg-color: black; --manga-reader-next-highlight-bg-color: transparent; --manga-reader-prev-highlight-bg-color: transparent; - + /* Radios */ --radio-accent-color: var(--primary-color); --radio-hover-accent-color: var(--primary-color-dark-shade); @@ -145,7 +149,9 @@ --carousel-hover-header-text-decoration: none; /** Drawer */ - --drawer-background-color: white; + --drawer-background-color: white; // TODO: Remove this for bg + --drawer-bg-color: white; + --drawer-text-color: black; /* Pagination */ --pagination-active-link-border-color: var(--primary-color); @@ -163,7 +169,7 @@ --event-widget-text-color: black; --event-widget-item-border-color: lightgrey; --event-widget-border-color: lightgrey; - + /* Popover */ --popover-body-bg-color: var(--navbar-bg-color); --popover-body-text-color: var(--navbar-text-color); @@ -172,4 +178,4 @@ --popover-bg-color: lightgrey; --popover-border-color: lightgrey; -} \ No newline at end of file +} diff --git a/UI/Web/src/theme/themes/light.scss b/UI/Web/src/theme/themes/light.scss index d8b040e16..10852ce00 100644 --- a/UI/Web/src/theme/themes/light.scss +++ b/UI/Web/src/theme/themes/light.scss @@ -3,24 +3,28 @@ --color-scheme: light; --primary-color: #4ac694; --primary-color-dark-shade: #3B9E76; + --primary-color-darker-shade: #338A67; + --primary-color-darkest-shade: #25624A; --error-color: #ff4136; - --bs-body-bg: #fff; + --bs-body-bg: #fff; --body-text-color: #333; - --btn-icon-filter: invert(1) grayscale(100%) brightness(200%); - + --btn-icon-filter: none; + /* Navbar */ --navbar-bg-color: black; --navbar-text-color: white; --navbar-fa-icon-color: white; --navbar-btn-hover-outline-color: rgba(255, 255, 255, 1); - + /* Inputs */ --input-bg-color: #fff; --input-focused-border-color: #ccc; --input-bg-readonly-color: unset; --input-placeholder-color: #aeaeae; --input-border-color: #ccc; - + --input-range-color: var(--primary-color); + --input-range-active-color: var(--primary-color-darker-shade); + /* Buttons */ --btn-primary-text-color: black; --btn-primary-bg-color: white; @@ -69,30 +73,31 @@ --side-nav-active-bg-color: rgba(0,0,0,0.5); --side-nav-item-active-text-color: white; --side-nav-overlay-color: rgba(0,0,0,0.5); - - + + /* Checkboxes */ --checkbox-checked-bg-color: var(--primary-color); --checkbox-bg-color: white; --checkbox-border-color: var(--primary-color); --checkbox-focus-border-color: var(--input-border-color); - + /* Tagbadge */ --tagbadge-bg-color: #c9c9c9; - + /* Toasts */ --toast-success-bg-color: rgba(74, 198, 148, 0.9); --toast-error-bg-color: #BD362F; --toast-info-bg-color: #2F96B4; --toast-warning-bg-color: #F89406; - + /* Rating star */ --ratingstar-star-empty: #b0c4de; --ratingstar-star-filled: var(--primary-color); - + /* Global */ - --accent-bg-color: rgba(206, 206, 206, 0.5); - --accent-text-color: black; + --accent-bg-color: rgba(206, 206, 206, 0.5); // Drawer had: var(--bs-body-bg) + --accent-text-color: grey; + --accent-text-size: 0.8rem; --hr-color: rgba(239, 239, 239, 0.125); --grid-breakpoints-xs: $grid-breakpoint-xs; --grid-breakpoints-sm: $grid-breakpoint-sm; @@ -102,7 +107,7 @@ --body-font-family: "EBGaramond", "Helvetica Neue", sans-serif; --brand-font-family: "Spartan", sans-serif; --text-muted-color: #aaa; - + /* Breadcrumb */ --breadcrumb-bg-color: #eaeaea; --breadcrumb-item-text-color: var(--body-text-color); @@ -113,7 +118,7 @@ --card-progress-bar-color: var(--primary-color); --card-overlay-bg-color: rgba(0, 0, 0, 0); --card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2); - + /* List items */ --list-group-item-text-color: var(--body-text-color); --list-group-item-bg-color: white; @@ -121,13 +126,13 @@ --list-group-hover-bg-color: #eaeaea; --list-group-item-border-color: rgba(239, 239, 239, 0.125); --list-group-active-border-color: none; - + /* Dropdown */ --dropdown-item-hover-text-color: white; --dropdown-item-hover-bg-color: var(--primary-color); --dropdown-overlay-color: rgba(0,0,0,0.5); --dropdown-item-bg-color: white; - + /* Manga Reader */ --manga-reader-overlay-filter: blur(10px); --manga-reader-overlay-bg-color: rgba(0,0,0,0.5); @@ -135,7 +140,7 @@ --manga-reader-bg-color: black; --manga-reader-next-highlight-bg-color: rgba(65, 225, 100, 0.5); --manga-reader-prev-highlight-bg-color: rgba(65, 105, 225, 0.5); - + /* Radios */ --radio-accent-color: var(--primary-color); --radio-hover-accent-color: var(--primary-color-dark-shade); @@ -146,7 +151,9 @@ --carousel-hover-header-text-decoration: underline; /** Drawer */ - --drawer-background-color: white; + --drawer-background-color: white; // TODO: Use bg + --drawer-bg-color: white; + --drawer-text-color: black; /* Pagination */ --pagination-active-link-border-color: var(--primary-color); @@ -172,4 +179,17 @@ --popover-arrow-color: lightgrey; --popover-bg-color: lightgrey; --popover-border-color: lightgrey; -} \ No newline at end of file + + /* Accordion */ + --accordion-header-text-color: rgba(74, 198, 148, 0.9); + --accordion-header-bg-color: var(--bs-body-bg); + --accordion-body-bg-color: var(--bs-body-bg); + --accordion-active-body-bg-color: var(--bs-body-bg); + --accordion-body-border-color: rgba(239, 239, 239, 0.125); + --accordion-body-text-color: var(--body-text-color); + --accordion-header-collapsed-text-color: var(--body-text-color); + --accordion-header-collapsed-bg-color: var(--bs-body-bg); + --accordion-button-focus-border-color: rgba(74, 198, 148, 0.9); + --accordion-button-focus-box-shadow: unset; + + } diff --git a/UI/Web/src/theme/utilities/_global.scss b/UI/Web/src/theme/utilities/_global.scss index 908b97dc6..4140273a8 100644 --- a/UI/Web/src/theme/utilities/_global.scss +++ b/UI/Web/src/theme/utilities/_global.scss @@ -20,6 +20,7 @@ hr { background-color: var(--accent-bg-color) !important; color: var(--accent-text-color) !important; box-shadow: inset 0px 0px 8px 1px var(--accent-bg-color) !important; + font-size: var(--accent-text-size) !important; } .text-muted {