diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 7de8bb2bf..fa20d7d87 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -310,7 +310,7 @@ namespace API.Tests.Services [InlineData(new [] {"001.txt", "002.txt", "a.jpg"}, "Test.zip", "a.jpg")] public void FindCoverImageFilename(string[] filenames, string archiveName, string expected) { - Assert.Equal(expected, _archiveService.FindCoverImageFilename(archiveName, filenames)); + Assert.Equal(expected, ArchiveService.FindCoverImageFilename(archiveName, filenames)); } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 3569aca2a..711e742e8 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -5,6 +5,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.Extensions; +using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,10 +15,12 @@ namespace API.Controllers public class UsersController : BaseApiController { private readonly IUnitOfWork _unitOfWork; + private readonly IMapper _mapper; - public UsersController(IUnitOfWork unitOfWork) + public UsersController(IUnitOfWork unitOfWork, IMapper mapper) { _unitOfWork = unitOfWork; + _mapper = mapper; } [Authorize(Policy = "RequireAdminRole")] @@ -71,7 +74,10 @@ namespace API.Controllers existingPreferences.ScalingOption = preferencesDto.ScalingOption; existingPreferences.PageSplitOption = preferencesDto.PageSplitOption; existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu; + existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints; existingPreferences.ReaderMode = preferencesDto.ReaderMode; + existingPreferences.LayoutMode = preferencesDto.LayoutMode; + existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor; existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin; existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing; existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily; @@ -90,5 +96,13 @@ namespace API.Controllers return BadRequest("There was an issue saving preferences."); } + + [HttpGet("get-preferences")] + public async Task> GetPreferences() + { + return _mapper.Map( + await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername())); + + } } } diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs index 6881cd0ae..4bfcb2d77 100644 --- a/API/DTOs/UserPreferencesDto.cs +++ b/API/DTOs/UserPreferencesDto.cs @@ -5,18 +5,74 @@ namespace API.DTOs { public class UserPreferencesDto { + /// + /// Manga Reader Option: What direction should the next/prev page buttons go + /// public ReadingDirection ReadingDirection { get; set; } + /// + /// Manga Reader Option: How should the image be scaled to screen + /// public ScalingOption ScalingOption { get; set; } + /// + /// Manga Reader Option: Which side of a split image should we show first + /// public PageSplitOption PageSplitOption { get; set; } + /// + /// Manga Reader Option: How the manga reader should perform paging or reading of the file + /// + /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging + /// by clicking top/bottom sides of reader. + /// + /// public ReaderMode ReaderMode { get; set; } + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + public LayoutMode LayoutMode { get; set; } + /// + /// Manga Reader Option: Background color of the reader + /// + public string BackgroundColor { get; set; } = "#000000"; + /// + /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction + /// public bool AutoCloseMenu { get; set; } + /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + public bool ShowScreenHints { get; set; } = true; + /// + /// Book Reader Option: Should the background color be dark + /// public bool BookReaderDarkMode { get; set; } = false; + /// + /// Book Reader Option: Override extra Margin + /// public int BookReaderMargin { get; set; } + /// + /// Book Reader Option: Override line-height + /// public int BookReaderLineSpacing { get; set; } + /// + /// Book Reader Option: Override font size + /// public int BookReaderFontSize { get; set; } + /// + /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override + /// public string BookReaderFontFamily { get; set; } + /// + /// Book Reader Option: Allows tapping on side of screens to paginate + /// public bool BookReaderTapToPaginate { get; set; } + /// + /// Book Reader Option: What direction should the next/prev page buttons go + /// public ReadingDirection BookReaderReadingDirection { get; set; } + /// + /// UI Site Global Setting: The UI theme the user should use. + /// + /// Should default to Dark public SiteTheme Theme { get; set; } } } diff --git a/API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.Designer.cs b/API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.Designer.cs new file mode 100644 index 000000000..a21ca1e92 --- /dev/null +++ b/API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.Designer.cs @@ -0,0 +1,1454 @@ +// +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("20220306155456_MangaReaderBackgroundAndLayoutMode")] + partial class MangaReaderBackgroundAndLayoutMode + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); + + 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") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + 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("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .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("Count") + .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("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("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.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("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("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.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.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("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.cs b/API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.cs new file mode 100644 index 000000000..078e51684 --- /dev/null +++ b/API/Data/Migrations/20220306155456_MangaReaderBackgroundAndLayoutMode.cs @@ -0,0 +1,38 @@ +using API.Entities.Enums; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class MangaReaderBackgroundAndLayoutMode : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "BackgroundColor", + table: "AppUserPreferences", + type: "TEXT", + defaultValue: "#000000", + nullable: false); + + migrationBuilder.AddColumn( + name: "LayoutMode", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: LayoutMode.Single); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "BackgroundColor", + table: "AppUserPreferences"); + + migrationBuilder.DropColumn( + name: "LayoutMode", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/20220307153053_ScreenHints.Designer.cs b/API/Data/Migrations/20220307153053_ScreenHints.Designer.cs new file mode 100644 index 000000000..f54b0ab0b --- /dev/null +++ b/API/Data/Migrations/20220307153053_ScreenHints.Designer.cs @@ -0,0 +1,1457 @@ +// +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("20220307153053_ScreenHints")] + partial class ScreenHints + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.2"); + + 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") + .HasColumnType("TEXT"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + 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("LayoutMode") + .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("Count") + .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("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("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.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("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("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.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.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("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20220307153053_ScreenHints.cs b/API/Data/Migrations/20220307153053_ScreenHints.cs new file mode 100644 index 000000000..6c7b67ade --- /dev/null +++ b/API/Data/Migrations/20220307153053_ScreenHints.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class ScreenHints : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ShowScreenHints", + table: "AppUserPreferences", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ShowScreenHints", + table: "AppUserPreferences"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 631d93a0b..d46e91af6 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -165,6 +165,9 @@ namespace API.Data.Migrations b.Property("AutoCloseMenu") .HasColumnType("INTEGER"); + b.Property("BackgroundColor") + .HasColumnType("TEXT"); + b.Property("BookReaderDarkMode") .HasColumnType("INTEGER"); @@ -186,6 +189,9 @@ namespace API.Data.Migrations b.Property("BookReaderTapToPaginate") .HasColumnType("INTEGER"); + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + b.Property("PageSplitOption") .HasColumnType("INTEGER"); @@ -198,6 +204,9 @@ namespace API.Data.Migrations b.Property("ScalingOption") .HasColumnType("INTEGER"); + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + b.Property("ThemeId") .HasColumnType("INTEGER"); diff --git a/API/Entities/AppUserPreferences.cs b/API/Entities/AppUserPreferences.cs index 38f95cf42..d35b82e39 100644 --- a/API/Entities/AppUserPreferences.cs +++ b/API/Entities/AppUserPreferences.cs @@ -25,12 +25,23 @@ namespace API.Entities /// /// public ReaderMode ReaderMode { get; set; } - /// /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction /// public bool AutoCloseMenu { get; set; } = true; /// + /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change + /// + public bool ShowScreenHints { get; set; } = true; + /// + /// Manga Reader Option: How many pages to display in the reader at once + /// + public LayoutMode LayoutMode { get; set; } = LayoutMode.Single; + /// + /// Manga Reader Option: Background color of the reader + /// + public string BackgroundColor { get; set; } = "#000000"; + /// /// Book Reader Option: Should the background color be dark /// public bool BookReaderDarkMode { get; set; } = true; diff --git a/API/Entities/Enums/LayoutMode.cs b/API/Entities/Enums/LayoutMode.cs new file mode 100644 index 000000000..58aaf30da --- /dev/null +++ b/API/Entities/Enums/LayoutMode.cs @@ -0,0 +1,11 @@ +using System.ComponentModel; + +namespace API.Entities.Enums; + +public enum LayoutMode +{ + [Description("Single")] + Single = 1, + [Description("Double")] + Double = 2 +} diff --git a/API/Entities/Enums/ReaderMode.cs b/API/Entities/Enums/ReaderMode.cs index 01ff7878f..94776252b 100644 --- a/API/Entities/Enums/ReaderMode.cs +++ b/API/Entities/Enums/ReaderMode.cs @@ -5,13 +5,10 @@ namespace API.Entities.Enums public enum ReaderMode { [Description("Left and Right")] - // ReSharper disable once InconsistentNaming - MANGA_LR = 0, + LeftRight = 0, [Description("Up and Down")] - // ReSharper disable once InconsistentNaming - MANGA_UP = 1, + UpDown = 1, [Description("Webtoon")] - // ReSharper disable once InconsistentNaming - WEBTOON = 2 + Webtoon = 2 } } diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index c1dd412e2..30a75a9eb 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -19,12 +19,13 @@ namespace API.Extensions /// Sorted Enumerable public static IEnumerable OrderByNatural(this IEnumerable items, Func selector, StringComparer stringComparer = null) { - var maxDigits = items + var list = items.ToList(); + var maxDigits = list .SelectMany(i => Regex.Matches(selector(i)) .Select(digitChunk => (int?)digitChunk.Value.Length)) .Max() ?? 0; - return items.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture); + return list.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture); } } } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index db098aa0f..fd29ea07d 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -27,7 +27,6 @@ namespace API.Services ArchiveLibrary CanOpen(string archivePath); bool ArchiveNeedsFlattening(ZipArchive archive); Task> CreateZipForDownload(IEnumerable files, string tempFolder); - string FindCoverImageFilename(string archivePath, IList entryNames); } /// @@ -127,8 +126,8 @@ namespace API.Services public static string FindFolderEntry(IEnumerable entryFullNames) { var result = entryFullNames - .OrderByNatural(Path.GetFileNameWithoutExtension) .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith))) + .OrderByNatural(Path.GetFileNameWithoutExtension) .FirstOrDefault(Parser.Parser.IsCoverImage); return string.IsNullOrEmpty(result) ? null : result; @@ -144,8 +143,8 @@ namespace API.Services // First check if there are any files that are not in a nested folder before just comparing by filename. This is needed // because NaturalSortComparer does not work with paths and doesn't seem 001.jpg as before chapter 1/001.jpg. var fullNames = entryFullNames - .OrderByNatural(c => c.GetFullPathWithoutExtension()) .Where(path => !(Path.EndsInDirectorySeparator(path) || Parser.Parser.HasBlacklistedFolderInPath(path) || path.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)) && Parser.Parser.IsImage(path)) + .OrderByNatural(c => c.GetFullPathWithoutExtension()) .ToList(); if (fullNames.Count == 0) return null; @@ -201,9 +200,8 @@ namespace API.Services case ArchiveLibrary.Default: { using var archive = ZipFile.OpenRead(archivePath); - var entryNames = archive.Entries.Select(e => e.FullName).ToList(); - var entryName = FindCoverImageFilename(archivePath, entryNames); + var entryName = FindCoverImageFilename(archivePath, archive.Entries.Select(e => e.FullName)); var entry = archive.Entries.Single(e => e.FullName == entryName); using var stream = entry.Open(); @@ -242,7 +240,7 @@ namespace API.Services /// /// /// - public string FindCoverImageFilename(string archivePath, IList entryNames) + public static string FindCoverImageFilename(string archivePath, IEnumerable entryNames) { var entryName = FindFolderEntry(entryNames) ?? FirstFileEntry(entryNames, Path.GetFileName(archivePath)); return entryName; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 3218190e5..0dcbc4f3b 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -79,6 +79,12 @@ public class SeriesService : ISeriesService series.Metadata.SummaryLocked = true; } + if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata.Language) + { + series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language; + series.Metadata.LanguageLocked = true; + } + series.Metadata.CollectionTags ??= new List(); UpdateRelatedList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) => diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 2a511d87a..be6e99a18 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -9173,6 +9173,14 @@ "tslib": "^2.0.0" } }, + "ngx-color-picker": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ngx-color-picker/-/ngx-color-picker-12.0.0.tgz", + "integrity": "sha512-SY5KoZka/uq2MNhUAKfJXQjjS2TFvKDJHbsCxfnjKjS/VHx8VVeTJpnt5wuuewzRzLxfOm5y2Fw8/HTPEPtRkA==", + "requires": { + "tslib": "^2.3.0" + } + }, "ngx-file-drop": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-13.0.0.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 07c7cc43e..2dbba8f3f 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -38,6 +38,7 @@ "file-saver": "^2.0.5", "lazysizes": "^5.3.2", "ng-circle-progress": "^1.6.0", + "ngx-color-picker": "^12.0.0", "ngx-file-drop": "^13.0.0", "ngx-toastr": "^14.2.1", "rxjs": "~7.5.4", diff --git a/UI/Web/src/app/_guards/library-access.guard.ts b/UI/Web/src/app/_guards/library-access.guard.ts index 611eb556d..8d10699f7 100644 --- a/UI/Web/src/app/_guards/library-access.guard.ts +++ b/UI/Web/src/app/_guards/library-access.guard.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { MemberService } from '../_services/member.service'; @Injectable({ @@ -12,6 +12,7 @@ export class LibraryAccessGuard implements CanActivate { canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { const libraryId = parseInt(state.url.split('library/')[1], 10); + if (isNaN(libraryId)) return of(false); return this.memberService.hasLibraryAccess(libraryId); } } diff --git a/UI/Web/src/app/_models/preferences/preferences.ts b/UI/Web/src/app/_models/preferences/preferences.ts index 2b0c885b0..89cdc8a61 100644 --- a/UI/Web/src/app/_models/preferences/preferences.ts +++ b/UI/Web/src/app/_models/preferences/preferences.ts @@ -1,6 +1,7 @@ +import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode'; import { PageSplitOption } from './page-split-option'; -import { READER_MODE } from './reader-mode'; +import { ReaderMode } from './reader-mode'; import { ReadingDirection } from './reading-direction'; import { ScalingOption } from './scaling-option'; import { SiteTheme } from './site-theme'; @@ -10,8 +11,11 @@ export interface Preferences { readingDirection: ReadingDirection; scalingOption: ScalingOption; pageSplitOption: PageSplitOption; - readerMode: READER_MODE; + readerMode: ReaderMode; autoCloseMenu: boolean; + layoutMode: LayoutMode; + backgroundColor: string; + showScreenHints: boolean; // Book Reader bookReaderDarkMode: boolean; @@ -29,4 +33,5 @@ export interface Preferences { export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}]; export const scalingOptions = [{text: 'Automatic', value: ScalingOption.Automatic}, {text: 'Fit to Height', value: ScalingOption.FitToHeight}, {text: 'Fit to Width', value: ScalingOption.FitToWidth}, {text: 'Original', value: ScalingOption.Original}]; 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: READER_MODE.MANGA_LR}, {text: 'Up to Down', value: READER_MODE.MANGA_UD}, {text: 'Webtoon', value: READER_MODE.WEBTOON}]; +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}]; diff --git a/UI/Web/src/app/_models/preferences/reader-mode.ts b/UI/Web/src/app/_models/preferences/reader-mode.ts index 3e3e16c96..cf3176ac8 100644 --- a/UI/Web/src/app/_models/preferences/reader-mode.ts +++ b/UI/Web/src/app/_models/preferences/reader-mode.ts @@ -1,14 +1,17 @@ -export enum READER_MODE { +/** + * The pagination method used by the reader + */ +export enum ReaderMode { /** * Manga default left/right to page */ - MANGA_LR = 0, + LeftRight = 0, /** * Manga up and down to page */ - MANGA_UD = 1, + UpDown = 1, /** * Webtoon reading (scroll) with optional areas to tap */ - WEBTOON = 2 + Webtoon = 2 } \ No newline at end of file diff --git a/UI/Web/src/app/_models/preferences/reading-direction.ts b/UI/Web/src/app/_models/preferences/reading-direction.ts index 48fab8ee1..e675d8f74 100644 --- a/UI/Web/src/app/_models/preferences/reading-direction.ts +++ b/UI/Web/src/app/_models/preferences/reading-direction.ts @@ -1,3 +1,6 @@ +/** + * Direction the user is reading. Maps to the pagination method. Not applicable with ReaderMode.Webtoon + */ export enum ReadingDirection { LeftToRight = 0, RightToLeft = 1 diff --git a/UI/Web/src/app/_models/preferences/scaling-option.ts b/UI/Web/src/app/_models/preferences/scaling-option.ts index 60d7d4e68..8486cbd18 100644 --- a/UI/Web/src/app/_models/preferences/scaling-option.ts +++ b/UI/Web/src/app/_models/preferences/scaling-option.ts @@ -1,6 +1,21 @@ +/** + * How the image should scale to the screen size + */ export enum ScalingOption { + /** + * Fit the image into the height of screen + */ FitToHeight = 0, + /** + * Fit the image into the width of screen + */ FitToWidth = 1, + /** + * Apply no logic and render the image as is + */ Original = 2, + /** + * Ask the reader to attempt to choose the best ScalingOption for the user + */ Automatic = 3 } diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 4d3a2747d..791c6643a 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -162,6 +162,20 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/update', model); } + /** + * This will get latest preferences for a user and cache them into user store + * @returns + */ + getPreferences() { + return this.httpClient.get(this.baseUrl + 'users/get-preferences').pipe(map(pref => { + if (this.currentUser !== undefined || this.currentUser != null) { + this.currentUser.preferences = pref; + this.setCurrentUser(this.currentUser); + } + return pref; + }), takeUntil(this.onDestroy)); + } + updatePreferences(userPreferences: Preferences) { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { if (this.currentUser !== undefined || this.currentUser != null) { diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index c1db7b1cd..33a788a88 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -114,11 +114,11 @@ export class ReaderService { /** * Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes */ - setOverrideStyles() { + setOverrideStyles(backgroundColor: string = 'black') { const bodyNode = document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null) { this.originalBodyColor = bodyNode.style.background; - bodyNode.setAttribute('style', 'background-color: black !important'); + bodyNode.setAttribute('style', 'background-color: ' + backgroundColor + ' !important'); } } diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index cebf755db..d0960294b 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -15,6 +15,7 @@ import { ThemeTestComponent } from './theme-test/theme-test.component'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules const routes: Routes = [ + {path: '', component: UserLoginComponent}, { path: 'admin', canActivate: [AdminGuard], @@ -69,8 +70,7 @@ const routes: Routes = [ ] }, {path: 'theme', component: ThemeTestComponent}, - - {path: '', component: UserLoginComponent}, + {path: 'login', component: UserLoginComponent}, // TODO: move this to registration module {path: '**', component: UserLoginComponent, pathMatch: 'full'} ]; diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index 071339229..72a38814e 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -8,7 +8,6 @@ import { NavService } from './_services/nav.service'; import { filter } from 'rxjs/operators'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { DOCUMENT } from '@angular/common'; -import { ThemeService } from './theme.service'; @Component({ selector: 'app-root', @@ -59,7 +58,7 @@ export class AppComponent implements OnInit { if (user) { this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user)); this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */}); - } + } } setDocHeight() { diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 3de4ae9ed..2bfb306a5 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -35,6 +35,7 @@ import { RegistrationModule } from './registration/registration.module'; import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component'; import { ThemeTestComponent } from './theme-test/theme-test.component'; import { PipeModule } from './pipe/pipe.module'; +import { ColorPickerModule } from 'ngx-color-picker'; @NgModule({ @@ -79,6 +80,8 @@ import { PipeModule } from './pipe/pipe.module'; ReadingListModule, RegistrationModule, + ColorPickerModule, // User preferences + NgbAccordionModule, // ThemeTest Component only PipeModule, diff --git a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts index 780882f1a..38dd16ff7 100644 --- a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.ts @@ -7,7 +7,6 @@ import { PageBookmark } from 'src/app/_models/page-bookmark'; import { Series } from 'src/app/_models/series'; import { ImageService } from 'src/app/_services/image.service'; import { ReaderService } from 'src/app/_services/reader.service'; -import { SeriesService } from 'src/app/_services/series.service'; @Component({ selector: 'app-bookmarks-modal', @@ -28,7 +27,7 @@ export class BookmarksModalComponent implements OnInit { constructor(public imageService: ImageService, private readerService: ReaderService, public modal: NgbActiveModal, private downloadService: DownloadService, - private toastr: ToastrService, private seriesService: SeriesService) { } + private toastr: ToastrService) { } ngOnInit(): void { this.init(); diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index 292c0c9d5..044fd2c34 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -112,7 +112,7 @@
-
+
-
+
@@ -138,7 +138,7 @@
-
+
diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index 4e0a8e787..fc1897afe 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -160,13 +160,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy { this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { this.metadata.ageRating = parseInt(val + '', 10); - if (!this.editSeriesForm.get('ageRating')?.touched) return; this.metadata.ageRatingLocked = true; }); this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { this.metadata.publicationStatus = parseInt(val + '', 10); - if (!this.editSeriesForm.get('publicationStatus')?.touched) return; this.metadata.publicationStatusLocked = true; }); } diff --git a/UI/Web/src/app/manga-reader/_models/layout-mode.ts b/UI/Web/src/app/manga-reader/_models/layout-mode.ts new file mode 100644 index 000000000..814112d5e --- /dev/null +++ b/UI/Web/src/app/manga-reader/_models/layout-mode.ts @@ -0,0 +1,14 @@ +/** + * How to layout pages for reading + */ +export enum LayoutMode { + /** + * Renders a single page on the renderer. Cover images will follow splitting logic. + */ + Single = 1, + /** + * Renders 2 pages side by side on the renderer. Cover images will not split and take up both panes. + */ + Double = 2, + +} \ No newline at end of file diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss index bc7e74ef1..1880bb678 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.scss @@ -50,4 +50,4 @@ 50% { filter: opacity(0.25); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index 864dc22d1..19575600c 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -12,7 +12,13 @@ {{subtitle}}
+
+ +
@@ -23,37 +29,49 @@
-
- - +
+ +
+ + +
+
+ + + + + +
+
+ + +
+ +
+
-
- -
-
- -
- -
+ + +
-
-
+
-
@@ -82,7 +100,7 @@
- @@ -142,11 +160,29 @@
-
- +
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.scss b/UI/Web/src/app/manga-reader/manga-reader.component.scss index d3d4917ab..b417f6f2c 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.scss +++ b/UI/Web/src/app/manga-reader/manga-reader.component.scss @@ -14,10 +14,6 @@ $pointer-offset: 5px; } } -// .btn-icon { -// color: white; -// } - canvas { position: absolute; } @@ -32,9 +28,6 @@ canvas { } - - - .loading { position: absolute; left: 48%; @@ -62,14 +55,21 @@ canvas { } // Fitting Options +// .full-height { +// position: absolute; +// margin: auto; +// top: 0px; +// left: 0; +// right: 0; +// bottom: 0px; +// height: 100%; +// } + .full-height { - position: absolute; - margin: auto; - top: 0px; - left: 0; - right: 0; - bottom: 0px; - height: 100%; + width: auto; + margin: 0 auto; + height: 100vh; + vertical-align: top; } .original { @@ -84,8 +84,29 @@ canvas { .full-width { width: 100%; + + &.double { + width: 50% + } + + .image-2 { + margin-left: 50%; + } } +.center-double { + display: block; + margin-left: auto; + margin-right: auto; +} + +.fit-to-height-double-offset { + width: 50%; +} + + + + .right { position: fixed; @@ -255,4 +276,4 @@ canvas { 50% { border: 5px solid var(--primary-color); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 34c62826d..eb14fbbaa 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -12,7 +12,7 @@ import { ScalingOption } from '../_models/preferences/scaling-option'; import { PageSplitOption } from '../_models/preferences/page-split-option'; import { BehaviorSubject, forkJoin, ReplaySubject, Subject } from 'rxjs'; import { ToastrService } from 'ngx-toastr'; -import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; +import { Breakpoint, KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { CircularArray } from '../shared/data-structures/circular-array'; import { MemberService } from '../_services/member.service'; import { Stack } from '../shared/data-structures/stack'; @@ -21,12 +21,15 @@ import { trigger, state, style, transition, animate } from '@angular/animations' import { ChapterInfo } from './_models/chapter-info'; import { FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from './_models/reader-enums'; import { pageSplitOptions, scalingOptions } from '../_models/preferences/preferences'; -import { READER_MODE } from '../_models/preferences/reader-mode'; +import { ReaderMode } from '../_models/preferences/reader-mode'; import { MangaFormat } from '../_models/manga-format'; import { LibraryService } from '../_services/library.service'; import { LibraryType } from '../_models/library'; +import { ShorcutsModalComponent } from '../reader-shared/_modals/shorcuts-modal/shorcuts-modal.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { LayoutMode } from './_models/layout-mode'; -const PREFETCH_PAGES = 5; +const PREFETCH_PAGES = 8; const CHAPTER_ID_NOT_FETCHED = -2; const CHAPTER_ID_DOESNT_EXIST = -1; @@ -101,7 +104,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD; isFullscreen: boolean = false; autoCloseMenu: boolean = true; - readerMode: READER_MODE = READER_MODE.MANGA_LR; + readerMode: ReaderMode = ReaderMode.LeftRight; pageSplitOptions = pageSplitOptions; @@ -110,7 +113,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { @ViewChild('reader') reader!: ElementRef; @ViewChild('content') canvas: ElementRef | undefined; private ctx!: CanvasRenderingContext2D; - canvasImage = new Image(); // private + /** + * Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer + */ + canvasImage = new Image(); + /** + * Used soley for LayoutMode.Double rendering. Will always hold the next image in buffer. + */ + canvasImage2 = new Image(); + renderWithCanvas: boolean = false; // Dictates if we use render with canvas or with image /** * A circular array of size PREFETCH_PAGES + 2. Maintains prefetched Images around the current page to load from to avoid loading animation. @@ -140,6 +151,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * If the menu is open/visible. */ menuOpen = false; + /** + * Image Viewer collapsed + */ + imageViewerCollapsed = true; /** * If the prev page allows a page change to occur. */ @@ -226,6 +241,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * Used for webtoon reader. When loading pages or data, this will disable the reader */ inSetup: boolean = true; + /** + * If we render 2 pages at once or 1 + */ + layoutMode: LayoutMode = LayoutMode.Single; + /** + * Background color for canvas/reader. User configured. + */ + backgroundColor: string = '#FFFFFF'; + private readonly onDestroy = new Subject(); @@ -233,7 +257,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { getPageUrl = (pageNum: number) => this.readerService.getPageUrl(this.chapterId, pageNum); - get pageBookmarked() { return this.bookmarks.hasOwnProperty(this.pageNum); } @@ -250,33 +273,46 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { get readerModeIcon() { switch(this.readerMode) { - case READER_MODE.MANGA_LR: + case ReaderMode.LeftRight: return 'fa-exchange-alt'; - case READER_MODE.MANGA_UD: + case ReaderMode.UpDown: return 'fa-exchange-alt fa-rotate-90'; - case READER_MODE.WEBTOON: + case ReaderMode.Webtoon: return 'fa-arrows-alt-v'; + default: + return ''; } } - get READER_MODE(): typeof READER_MODE { - return READER_MODE; + get ReaderMode() { + return ReaderMode; + } + get LayoutMode() { + return LayoutMode; } - get ReadingDirection(): typeof ReadingDirection { + get ReadingDirection() { return ReadingDirection; } - get PageSplitOption(): typeof PageSplitOption { + get PageSplitOption() { return PageSplitOption; } + get Breakpoint() { + return Breakpoint; + } + + get FITTING_OPTION() { + return FITTING_OPTION; + } + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, public readerService: ReaderService, private location: Location, private formBuilder: FormBuilder, private navService: NavService, private toastr: ToastrService, private memberService: MemberService, - private libraryService: LibraryService, private utilityService: UtilityService, - private renderer: Renderer2, @Inject(DOCUMENT) private document: Document) { + private libraryService: LibraryService, public utilityService: UtilityService, + private renderer: Renderer2, @Inject(DOCUMENT) private document: Document, private modalService: NgbModal) { this.navService.hideNavBar(); } @@ -304,8 +340,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.continuousChaptersStack.push(this.chapterId); - this.readerService.setOverrideStyles(); - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.user = user; @@ -314,16 +348,28 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pageSplitOption = this.user.preferences.pageSplitOption; this.autoCloseMenu = this.user.preferences.autoCloseMenu; this.readerMode = this.user.preferences.readerMode; + this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single; + this.backgroundColor = this.user.preferences.backgroundColor || '#000000'; + this.readerService.setOverrideStyles(this.backgroundColor); this.generalSettingsForm = this.formBuilder.group({ autoCloseMenu: this.autoCloseMenu, pageSplitOption: this.pageSplitOption, - fittingOption: this.translateScalingOption(this.scalingOption) + fittingOption: this.translateScalingOption(this.scalingOption), + layoutMode: this.layoutMode }); this.updateForm(); + this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => { + this.layoutMode = parseInt(val, 10); + if (this.layoutMode === LayoutMode.Double) { + // Update canvasImage2 + this.canvasImage2 = this.cachedImages.next(); + } + }); + this.generalSettingsForm.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((changes: SimpleChanges) => { this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value; @@ -370,17 +416,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { @HostListener('window:keyup', ['$event']) handleKeyPress(event: KeyboardEvent) { - switch (this.readerMode) { - case READER_MODE.MANGA_LR: + case ReaderMode.LeftRight: if (event.key === KEY_CODES.RIGHT_ARROW) { this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage(); } else if (event.key === KEY_CODES.LEFT_ARROW) { this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage(); } break; - case READER_MODE.MANGA_UD: - case READER_MODE.WEBTOON: + case ReaderMode.UpDown: + case ReaderMode.Webtoon: if (event.key === KEY_CODES.DOWN_ARROW) { this.nextPage() } else if (event.key === KEY_CODES.UP_ARROW) { @@ -514,7 +559,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } render() { - if (this.readerMode === READER_MODE.WEBTOON) { + if (this.readerMode === ReaderMode.Webtoon) { this.isLoading = false; } else { this.loadPage(); @@ -535,6 +580,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.title += ' - ' + chapterInfo.chapterTitle; } + // TODO: Move this to the backend this.subtitle = ''; if (chapterInfo.isSpecial && chapterInfo.volumeNumber === '0') { this.subtitle = chapterInfo.fileName; @@ -581,10 +627,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { getFittingOptionClass() { const formControl = this.generalSettingsForm.get('fittingOption'); + let val = FITTING_OPTION.HEIGHT; if (formControl === undefined) { - return FITTING_OPTION.HEIGHT; + val = FITTING_OPTION.HEIGHT; } - return formControl?.value; + val = formControl?.value; + + if (this.isCoverImage() && this.shouldRenderAsFitSplit()) { + // Rewriting to fit to width for this cover image + val = FITTING_OPTION.WIDTH; + } + + if (!this.isCoverImage() && this.layoutMode === LayoutMode.Double) { + return val + ' double'; + } + return val; } getFittingIcon() { @@ -703,7 +760,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } handlePageChange(event: any, direction: string) { - if (this.readerMode === READER_MODE.WEBTOON) { + if (this.readerMode === ReaderMode.Webtoon) { if (direction === 'right') { this.nextPage(event); } else { @@ -738,12 +795,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.pagingDirection = PAGING_DIRECTION.FORWARD; if (this.isNoSplit() || notInSplit) { this.setPageNum(this.pageNum + 1); - if (this.readerMode !== READER_MODE.WEBTOON) { + if (this.readerMode !== ReaderMode.Webtoon) { this.canvasImage = this.cachedImages.next(); + this.canvasImage2 = this.cachedImages.peek(2); + console.log('[nextPage] canvasImage: ', this.canvasImage); + console.log('[nextPage] canvasImage2: ', this.canvasImage2); } } - if (this.readerMode !== READER_MODE.WEBTOON) { + if (this.readerMode !== ReaderMode.Webtoon) { this.loadPage(); } } @@ -769,9 +829,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.isNoSplit() || notInSplit) { this.setPageNum(this.pageNum - 1); this.canvasImage = this.cachedImages.prev(); + this.canvasImage2 = this.cachedImages.peek(-2); + console.log('[prevPage] canvasImage: ', this.canvasImage); + console.log('[prevPage] canvasImage2: ', this.canvasImage2); } - if (this.readerMode !== READER_MODE.WEBTOON) { + if (this.readerMode !== ReaderMode.Webtoon) { this.loadPage(); } } @@ -877,69 +940,19 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { const needsSplitting = this.isCoverImage(); this.updateSplitPage(); + if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.LEFT_PART) { this.canvas.nativeElement.width = this.canvasImage.width / 2; this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, 0, 0, this.canvasImage.width, this.canvasImage.height); + this.renderWithCanvas = true; + console.log('[Render] Canvas') } else if (needsSplitting && this.currentImageSplitPart === SPLIT_PAGE_PART.RIGHT_PART) { this.canvas.nativeElement.width = this.canvasImage.width / 2; this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height); + this.renderWithCanvas = true; + console.log('[Render] Canvas') } else { - if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) { - this.updateScalingForFirstPageRender(); - } - - // Fit Split on a page that needs splitting - if (!this.shouldRenderAsFitSplit()) { - this.setCanvasSize(); - this.ctx.drawImage(this.canvasImage, 0, 0); - - // Reset scroll on non HEIGHT Fits - if (this.getFit() !== FITTING_OPTION.HEIGHT) { - window.scrollTo(0, 0); - } - - this.isLoading = false; - return; - } - - const windowWidth = window.innerWidth - || document.documentElement.clientWidth - || document.body.clientWidth; - const windowHeight = window.innerHeight - || document.documentElement.clientHeight - || document.body.clientHeight; - // If the user's screen is wider than the image, just pretend this is no split, as it will render nicer - this.canvas.nativeElement.width = windowWidth; - this.canvas.nativeElement.height = windowHeight; - const ratio = this.canvasImage.width / this.canvasImage.height; - let newWidth = windowWidth; - let newHeight = newWidth / ratio; - if (newHeight > windowHeight) { - newHeight = windowHeight; - newWidth = newHeight * ratio; - } - - // Optimization: When the screen is larger than newWidth, allow no split rendering to occur for a better fit - // if (windowWidth > newWidth) { - // this.setCanvasSize(); - // this.ctx.drawImage(this.canvasImage, 0, 0); - // } else { - // this.setCanvasSize(); - // //this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); - // this.ctx.drawImage(this.canvasImage, 0, 0, newWidth, newHeight); - // } - - this.setCanvasSize(); - // var offScreenCanvas = document.createElement('canvas') - // offScreenCanvas.width = newWidth; - // offScreenCanvas.height = newHeight; -// const resizedImage = new Image(); - // pica.resize(this.canvasImage, offScreenCanvas); - - - - //this.document.querySelector('.reading-area')?.appendChild(this.canvasImage); - + this.renderWithCanvas = false; } // Reset scroll on non HEIGHT Fits @@ -984,7 +997,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { shouldRenderAsFitSplit() { // Some pages aren't cover images but might need fit split renderings if (parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false; - //if (!this.isCoverImage() || parseInt(this.generalSettingsForm?.get('pageSplitOption')?.value, 10) !== PageSplitOption.FitSplit) return false; return true; } @@ -1011,9 +1023,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.isLoading = true; this.canvasImage = this.cachedImages.current(); + this.canvasImage2 = this.cachedImages.next(); // TODO: Do I need this here? + console.log('[loadPage] canvasImage: ', this.canvasImage); + console.log('[loadPage] canvasImage2: ', this.canvasImage2); if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) { this.canvasImage.src = this.readerService.getPageUrl(this.chapterId, this.pageNum); + this.canvasImage2.src = this.readerService.getPageUrl(this.chapterId, this.pageNum + 1); // TODO: I need to handle last page correctly this.canvasImage.onload = () => this.renderPage(); + + console.log('[loadPage] (after setting) canvasImage: ', this.canvasImage); + console.log('[loadPage] (after setting) canvasImage2: ', this.canvasImage2); } else { this.renderPage(); } @@ -1027,7 +1046,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.readingDirection = ReadingDirection.LeftToRight; } - if (this.menuOpen) { + if (this.menuOpen && this.user.preferences.showScreenHints) { this.showClickOverlay = true; setTimeout(() => { this.showClickOverlay = false; @@ -1039,7 +1058,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { sliderDragUpdate(context: ChangeContext) { // This will update the value for value except when in webtoon due to how the webtoon reader // responds to page changes - if (this.readerMode !== READER_MODE.WEBTOON) { + if (this.readerMode !== ReaderMode.Webtoon) { this.setPageNum(context.value); } } @@ -1142,15 +1161,15 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { toggleReaderMode() { switch(this.readerMode) { - case READER_MODE.MANGA_LR: - this.readerMode = READER_MODE.MANGA_UD; + case ReaderMode.LeftRight: + this.readerMode = ReaderMode.UpDown; this.pagingDirection = PAGING_DIRECTION.FORWARD; break; - case READER_MODE.MANGA_UD: - this.readerMode = READER_MODE.WEBTOON; + case ReaderMode.UpDown: + this.readerMode = ReaderMode.Webtoon; break; - case READER_MODE.WEBTOON: - this.readerMode = READER_MODE.MANGA_LR; + case ReaderMode.Webtoon: + this.readerMode = ReaderMode.LeftRight; break; } @@ -1160,7 +1179,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } updateForm() { - if ( this.readerMode === READER_MODE.WEBTOON) { + if ( this.readerMode === ReaderMode.Webtoon) { this.generalSettingsForm.get('fittingOption')?.disable() this.generalSettingsForm.get('pageSplitOption')?.disable(); } else { @@ -1178,23 +1197,45 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ bookmarkPage() { const pageNum = this.pageNum; + + // TODO: Handle LayoutMode.Double + if (this.pageBookmarked) { - this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => { + let apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)]; + if (this.layoutMode === LayoutMode.Double) apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1)); + forkJoin(apis).pipe(take(1)).subscribe(() => { delete this.bookmarks[pageNum]; }); } else { - this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => { + let apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)]; + if (this.layoutMode === LayoutMode.Double) apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1)); + forkJoin(apis).pipe(take(1)).subscribe(() => { this.bookmarks[pageNum] = 1; }); } // Show an effect on the image to show that it was bookmarked this.showBookmarkEffectEvent.next(pageNum); - if (this.readerMode != READER_MODE.WEBTOON) { - if (this.canvas) { - this.renderer.addClass(this.canvas?.nativeElement, 'bookmark-effect'); + if (this.readerMode != ReaderMode.Webtoon) { + + let elements:Array = []; + if (this.renderWithCanvas && this.canvas) { + elements.push(this.canvas?.nativeElement); + } else { + const image1 = this.document.querySelector('#image-1'); + if (image1 != null) elements.push(image1); + + if (this.layoutMode === LayoutMode.Double) { + const image2 = this.document.querySelector('#image-2'); + if (image2 != null) elements.push(image2); + } + } + + + if (elements.length > 0) { + elements.forEach(elem => this.renderer.addClass(elem, 'bookmark-effect')); setTimeout(() => { - this.renderer.removeClass(this.canvas?.nativeElement, 'bookmark-effect'); + elements.forEach(elem => this.renderer.removeClass(elem, 'bookmark-effect')); }, 1000); } } @@ -1220,4 +1261,18 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { || document.body.clientHeight; return [windowWidth, windowHeight]; } + + openShortcutModal() { + let ref = this.modalService.open(ShorcutsModalComponent, { scrollable: true, size: 'md' }); + ref.componentInstance.shortcuts = [ + {key: '⇽', description: 'Move to previous page'}, + {key: '⇾', description: 'Move to next page'}, + {key: '↑', description: 'Move to previous page'}, + {key: '↓', description: 'Move to previous page'}, + {key: 'G', description: 'Open Go to Page dialog'}, + {key: 'B', description: 'Bookmark current page'}, + {key: 'ESC', description: 'Close reader'}, + {key: 'SPACE', description: 'Toggle Menu'}, + ]; + } } diff --git a/UI/Web/src/app/manga-reader/manga-reader.module.ts b/UI/Web/src/app/manga-reader/manga-reader.module.ts index 69f6b4501..0d15ebe03 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.module.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.module.ts @@ -7,6 +7,7 @@ import { MangaReaderRoutingModule } from './manga-reader.router.module'; import { SharedModule } from '../shared/shared.module'; import { NgxSliderModule } from '@angular-slider/ngx-slider'; import { InfiniteScrollerComponent } from './infinite-scroller/infinite-scroller.component'; +import { ReaderSharedModule } from '../reader-shared/reader-shared.module'; @NgModule({ declarations: [ @@ -22,6 +23,7 @@ import { InfiniteScrollerComponent } from './infinite-scroller/infinite-scroller NgbDropdownModule, NgxSliderModule, SharedModule, + ReaderSharedModule, ], exports: [ MangaReaderComponent diff --git a/UI/Web/src/app/pipe/pipe.module.ts b/UI/Web/src/app/pipe/pipe.module.ts index fba9c2d7e..3641396e5 100644 --- a/UI/Web/src/app/pipe/pipe.module.ts +++ b/UI/Web/src/app/pipe/pipe.module.ts @@ -1,8 +1,9 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FilterPipe } from './filter.pipe'; -import { PersonRolePipe } from './person-role.pipe'; import { PublicationStatusPipe } from './publication-status.pipe'; +import { SentenceCasePipe } from './sentence-case.pipe'; +import { PersonRolePipe } from './person-role.pipe'; @@ -10,7 +11,8 @@ import { PublicationStatusPipe } from './publication-status.pipe'; declarations: [ FilterPipe, PersonRolePipe, - PublicationStatusPipe + PublicationStatusPipe, + SentenceCasePipe ], imports: [ CommonModule, @@ -18,7 +20,8 @@ import { PublicationStatusPipe } from './publication-status.pipe'; exports: [ FilterPipe, PersonRolePipe, - PublicationStatusPipe + PublicationStatusPipe, + SentenceCasePipe ] }) export class PipeModule { } diff --git a/UI/Web/src/app/shared/sentence-case.pipe.ts b/UI/Web/src/app/pipe/sentence-case.pipe.ts similarity index 100% rename from UI/Web/src/app/shared/sentence-case.pipe.ts rename to UI/Web/src/app/pipe/sentence-case.pipe.ts diff --git a/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.html b/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.html new file mode 100644 index 000000000..37d5cabe4 --- /dev/null +++ b/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.html @@ -0,0 +1,14 @@ + + + diff --git a/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.scss b/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.ts b/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.ts new file mode 100644 index 000000000..2b33bcc4b --- /dev/null +++ b/UI/Web/src/app/reader-shared/_modals/shorcuts-modal/shorcuts-modal.component.ts @@ -0,0 +1,34 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +export interface KeyboardShortcut { + /** + * String representing key or key combo. Should use + for combos. Will render as upper case + */ + key: string; + /** + * Description of how it works + */ + description: string; +} + +@Component({ + selector: 'app-shorcuts-modal', + templateUrl: './shorcuts-modal.component.html', + styleUrls: ['./shorcuts-modal.component.scss'] +}) +export class ShorcutsModalComponent implements OnInit { + + @Input() shortcuts: Array = []; + + constructor(public modal: NgbActiveModal) { } + + ngOnInit(): void { + } + + + close() { + this.modal.close(); + } + +} diff --git a/UI/Web/src/app/reader-shared/reader-shared.module.ts b/UI/Web/src/app/reader-shared/reader-shared.module.ts new file mode 100644 index 000000000..49790215d --- /dev/null +++ b/UI/Web/src/app/reader-shared/reader-shared.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ShorcutsModalComponent } from './_modals/shorcuts-modal/shorcuts-modal.component'; +import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; + + + +@NgModule({ + declarations: [ + ShorcutsModalComponent + ], + imports: [ + CommonModule, + NgbModalModule + ], + exports: [ + ShorcutsModalComponent + ] +}) +export class ReaderSharedModule { } diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 40eadd801..08b8d42c7 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -522,7 +522,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { } openEditSeriesModal() { - const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile) + const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'xl' }); modalRef.componentInstance.series = this.series; modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => { window.scrollTo(0, 0); diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 1a6c17a6c..874bbd0b4 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -14,7 +14,6 @@ import { SeriesFormatComponent } from './series-format/series-format.component'; import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component'; import { CircularLoaderComponent } from './circular-loader/circular-loader.component'; import { NgCircleProgressModule } from 'ng-circle-progress'; -import { SentenceCasePipe } from './sentence-case.pipe'; import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { BadgeExpanderComponent } from './badge-expander/badge-expander.component'; import { ImageComponent } from './image/image.component'; @@ -31,7 +30,6 @@ import { ImageComponent } from './image/image.component'; SeriesFormatComponent, UpdateNotificationModalComponent, CircularLoaderComponent, - SentenceCasePipe, PersonBadgeComponent, BadgeExpanderComponent, ImageComponent @@ -46,7 +44,6 @@ import { ImageComponent } from './image/image.component'; ], exports: [ SafeHtmlPipe, // Used globally - SentenceCasePipe, // Used globablly ReadMoreComponent, // Used globably DrawerComponent, // Can be replaced with boostrap offscreen canvas (v5) ShowIfScrollbarDirective, // Used book reader only? diff --git a/UI/Web/src/app/typeahead/typeahead.component.html b/UI/Web/src/app/typeahead/typeahead.component.html index bacb08e08..c49d5e099 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.html +++ b/UI/Web/src/app/typeahead/typeahead.component.html @@ -5,7 +5,7 @@ Field is locked
-
+
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 9e2a4cfb0..3c6d07acb 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 @@ -94,14 +94,49 @@
+ +
+
+   + Render a single image to the screen to two side-by-side images + + +
+
+ + +
+
-
- -
-
- - +
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+ + +
+
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 a794452d2..d5a0cbfd9 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 @@ -5,12 +5,13 @@ 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 } from 'src/app/_models/preferences/preferences'; +import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, 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 } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { SettingsService } from 'src/app/admin/settings.service'; +import { forkJoin } from 'rxjs'; @Component({ selector: 'app-user-preferences', @@ -23,6 +24,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { scalingOptions = scalingOptions; pageSplitOptions = pageSplitOptions; readingModes = readingModes; + layoutModes = layoutModes; settingsForm: FormGroup = new FormGroup({}); passwordChangeForm: FormGroup = new FormGroup({}); @@ -63,8 +65,11 @@ 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 + constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService, - private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService) { + private navService: NavService, private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService, + private router: Router) { this.fontFamilies = this.bookService.getFontFamilies(); this.route.fragment.subscribe(frag => { @@ -83,31 +88,41 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { ngOnInit(): void { this.titleService.setTitle('Kavita - User Preferences'); - this.accountService.currentUser$.pipe(take(1)).subscribe((user) => { - if (user) { - this.user = user; - this.isAdmin = this.accountService.hasAdminRole(user); - this.hasChangePasswordRole = this.accountService.hasChangePasswordRole(user); - if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) { - this.user.preferences.bookReaderFontFamily = 'default'; - } - - this.settingsForm.addControl('readingDirection', new FormControl(user.preferences.readingDirection, [])); - this.settingsForm.addControl('scalingOption', new FormControl(user.preferences.scalingOption, [])); - this.settingsForm.addControl('pageSplitOption', new FormControl(user.preferences.pageSplitOption, [])); - this.settingsForm.addControl('autoCloseMenu', new FormControl(user.preferences.autoCloseMenu, [])); - this.settingsForm.addControl('readerMode', new FormControl(user.preferences.readerMode, [])); - this.settingsForm.addControl('bookReaderDarkMode', new FormControl(user.preferences.bookReaderDarkMode, [])); - this.settingsForm.addControl('bookReaderFontFamily', new FormControl(user.preferences.bookReaderFontFamily, [])); - this.settingsForm.addControl('bookReaderFontSize', new FormControl(user.preferences.bookReaderFontSize, [])); - this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(user.preferences.bookReaderLineSpacing, [])); - this.settingsForm.addControl('bookReaderMargin', new FormControl(user.preferences.bookReaderMargin, [])); - this.settingsForm.addControl('bookReaderReadingDirection', new FormControl(user.preferences.bookReaderReadingDirection, [])); - this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(!!user.preferences.bookReaderTapToPaginate, [])); - - this.settingsForm.addControl('theme', new FormControl(user.preferences.theme, [])); + forkJoin({ + user: this.accountService.currentUser$.pipe(take(1)), + pref: this.accountService.getPreferences() + }).subscribe(results => { + if (results.user === undefined) { + this.router.navigateByUrl('/login'); + return; } + + this.user = results.user; + this.user.preferences = results.pref; + this.isAdmin = this.accountService.hasAdminRole(results.user); + this.hasChangePasswordRole = this.accountService.hasChangePasswordRole(results.user); + + if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) { + this.user.preferences.bookReaderFontFamily = 'default'; + } + + this.settingsForm.addControl('readingDirection', new FormControl(this.user.preferences.readingDirection, [])); + this.settingsForm.addControl('scalingOption', new FormControl(this.user.preferences.scalingOption, [])); + this.settingsForm.addControl('pageSplitOption', new FormControl(this.user.preferences.pageSplitOption, [])); + this.settingsForm.addControl('autoCloseMenu', new FormControl(this.user.preferences.autoCloseMenu, [])); + this.settingsForm.addControl('showScreenHints', new FormControl(this.user.preferences.showScreenHints, [])); + this.settingsForm.addControl('readerMode', new FormControl(this.user.preferences.readerMode, [])); + this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.layoutMode, [])); + this.settingsForm.addControl('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('theme', new FormControl(this.user.preferences.theme, [])); }); this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required])); @@ -131,7 +146,9 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { this.settingsForm.get('readingDirection')?.setValue(this.user.preferences.readingDirection); this.settingsForm.get('scalingOption')?.setValue(this.user.preferences.scalingOption); this.settingsForm.get('autoCloseMenu')?.setValue(this.user.preferences.autoCloseMenu); + this.settingsForm.get('showScreenHints')?.setValue(this.user.preferences.showScreenHints); 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); @@ -157,7 +174,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy { scalingOption: parseInt(modelSettings.scalingOption, 10), pageSplitOption: parseInt(modelSettings.pageSplitOption, 10), autoCloseMenu: modelSettings.autoCloseMenu, - readerMode: parseInt(modelSettings.readerMode), + readerMode: parseInt(modelSettings.readerMode, 10), + layoutMode: parseInt(modelSettings.layoutMode, 10), + showScreenHints: modelSettings.showScreenHints, + backgroundColor: this.user.preferences.backgroundColor, bookReaderDarkMode: modelSettings.bookReaderDarkMode, bookReaderFontFamily: modelSettings.bookReaderFontFamily, bookReaderLineSpacing: modelSettings.bookReaderLineSpacing, 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 33de715b0..d7ce36f44 100644 --- a/UI/Web/src/app/user-settings/user-settings.module.ts +++ b/UI/Web/src/app/user-settings/user-settings.module.ts @@ -10,7 +10,8 @@ import { ApiKeyComponent } from './api-key/api-key.component'; import { SharedModule } from '../shared/shared.module'; import { ThemeManagerComponent } from './theme-manager/theme-manager.component'; import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe'; - +import { PipeModule } from '../pipe/pipe.module'; +import { ColorPickerModule } from 'ngx-color-picker'; @@ -30,7 +31,9 @@ import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe'; NgbTooltipModule, NgxSliderModule, UserSettingsRoutingModule, - SharedModule // SentenceCase pipe + //SharedModule, // SentenceCase pipe + PipeModule, + ColorPickerModule, // User prefernces background color ], exports: [ SiteThemeProviderPipe