Custom Cover Images (#499)

* Added some documentation. Removed Require Admin Role from Search Tags. Added Summary to be updated on UpdateTag.

* Added Swagger xml doc generation to beef up the documentation. Started adding xml comments to the APIs. This is a needed, slow task for upcoming Plugins system.

* Implemented the ability to upload a custom series image to override the existing cover image.

Refactored some code out to use ImageService and added more documentation

* When a page cache fails, delete cache directory so user can try to reload.

* Implemented the ability to lock a series cover image such that after user uploads something, it wont get refreshed by Kavita.

* Implemented the ability to reset cover image for series by unlocking

* Kick off a series refresh after a cover is unlocked.

* Ability to press enter to load a url

* Ability to reset selection

* Cleaned up cover chooser such that reset is nicer, errors inform user to use file upload, series edit modal now doesn't use scrollable body. Mobile tweaks. CoverImageLocked is now sent to the UI.

* More css changes to look better

* When no bookmarks, don't show both markups

* Fixed issues where images wouldn't refresh after cover image was changed.

* Implemented the ability to change the cover images for collection tags.

* Added property and API for chapter cover image update

* Added UI code to prepare for updating cover image for chapters. need to rearrange components

* Moved a ton of code around to separate card related screens into their own module.

* Implemented the ability to update a chapter/volume cover image

* Refactored action for volume to say edit to reflect modal action

* Fixed issue where after editing chapter cover image, the underlying card wouldn't update

* Fixed an issue where we were passing volumeId to the reset chapter lock. Changed some logic in volume cover image generation.

* Automatically apply when you hit reset cover image
This commit is contained in:
Joseph Milazzo 2021-08-15 10:36:47 -07:00 committed by GitHub
parent 30387bc370
commit 2fd02f0d2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 3364 additions and 20668 deletions

View File

@ -22,11 +22,12 @@ namespace API.Tests.Parser
[InlineData("Invincible Vol 01 Family matters (2005) (Digital).cbr", "Invincible")]
[InlineData("Amazing Man Comics chapter 25", "Amazing Man Comics")]
[InlineData("Amazing Man Comics issue #25", "Amazing Man Comics")]
[InlineData("Teen Titans v1 038 (1972) (c2c).cbr", "Teen Titans")]
public void ParseComicSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));
}
[Theory]
[InlineData("01 Spider-Man & Wolverine 01.cbr", "1")]
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "4")]
@ -47,7 +48,7 @@ namespace API.Tests.Parser
{
Assert.Equal(expected, API.Parser.Parser.ParseComicVolume(filename));
}
[Theory]
[InlineData("01 Spider-Man & Wolverine 01.cbr", "0")]
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")]
@ -70,4 +71,4 @@ namespace API.Tests.Parser
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));
}
}
}
}

View File

@ -12,6 +12,10 @@
<ApplicationIcon>../favicon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\API.xml</DocumentationFile>
</PropertyGroup>
<!-- Set the Product and Version info for our own projects -->
<PropertyGroup>
<Product>Kavita</Product>
@ -81,6 +85,8 @@
<None Remove="backups\**" />
<None Remove="logs\**" />
<None Remove="temp\**" />
<None Remove="kavita.log" />
<None Remove="kavita.db" />
</ItemGroup>
<ItemGroup>

View File

@ -13,17 +13,25 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
/// <summary>
/// APIs for Collections
/// </summary>
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
}
/// <summary>
/// Return a list of all collection tags on the server
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IEnumerable<CollectionTagDto>> GetAllTags()
{
@ -31,11 +39,17 @@ namespace API.Controllers
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
if (isAdmin)
{
return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
}
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
}
/// <summary>
/// Searches against the collection tags on the DB and returns matches that meet the search criteria.
/// <remarks>Search strings will be cleaned of certain fields, like %</remarks>
/// </summary>
/// <param name="queryString">Search term</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("search")]
public async Task<IEnumerable<CollectionTagDto>> SearchTags(string queryString)
@ -43,20 +57,27 @@ namespace API.Controllers
queryString ??= "";
queryString = queryString.Replace(@"%", "");
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
}
/// <summary>
/// Updates an existing tag with a new title, promotion status, and summary.
/// <remarks>UI does not contain controls to update title</remarks>
/// </summary>
/// <param name="updatedTag"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
public async Task<ActionResult> UpdateTagPromotion(CollectionTagDto updatedTag)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id);
if (existingTag == null) return BadRequest("This tag does not exist");
existingTag.Promoted = updatedTag.Promoted;
existingTag.Title = updatedTag.Title;
existingTag.Title = updatedTag.Title.Trim();
existingTag.NormalizedTitle = Parser.Parser.Normalize(updatedTag.Title).ToUpper();
existingTag.Summary = updatedTag.Summary.Trim();
if (_unitOfWork.HasChanges())
{
@ -73,6 +94,11 @@ namespace API.Controllers
return BadRequest("Something went wrong, please try again");
}
/// <summary>
/// For a given tag, update the summary if summary has changed and remove a set of series from the tag.
/// </summary>
/// <param name="updateSeriesForTagDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task<ActionResult> UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto)
@ -90,6 +116,13 @@ namespace API.Controllers
_unitOfWork.CollectionTagRepository.Update(tag);
}
if (!updateSeriesForTagDto.Tag.CoverImageLocked)
{
tag.CoverImageLocked = false;
tag.CoverImage = Array.Empty<byte>();
_unitOfWork.CollectionTagRepository.Update(tag);
}
foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
@ -101,7 +134,9 @@ namespace API.Controllers
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
if (!_unitOfWork.HasChanges()) return Ok("No updates");
if (await _unitOfWork.CommitAsync())
{
return Ok("Tag updated");
}
@ -110,9 +145,9 @@ namespace API.Controllers
{
await _unitOfWork.RollbackAsync();
}
return BadRequest("Something went wrong. Please try again.");
}
}
}
}

View File

@ -5,57 +5,78 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
/// <summary>
/// Responsible for servicing up images stored in the DB
/// </summary>
public class ImageController : BaseApiController
{
private const string Format = "jpeg";
private readonly IUnitOfWork _unitOfWork;
/// <inheritdoc />
public ImageController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Returns cover image for Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-cover")]
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
{
var content = await _unitOfWork.VolumeRepository.GetChapterCoverImageAsync(chapterId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"chapterId");
return File(content, "image/" + Format, $"{chapterId}");
}
/// <summary>
/// Returns cover image for Volume
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
{
var content = await _unitOfWork.SeriesRepository.GetVolumeCoverImageAsync(volumeId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"volumeId");
return File(content, "image/" + Format, $"{volumeId}");
}
/// <summary>
/// Returns cover image for Series
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId)
{
var content = await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"seriesId");
return File(content, "image/" + Format, $"{seriesId}");
}
/// <summary>
/// Returns cover image for Collection Tag
/// </summary>
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
{
var content = await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"collectionTagId");
return File(content, "image/" + Format, $"{collectionTagId}");
}
}
}
}

View File

@ -37,16 +37,24 @@ namespace API.Controllers
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
try
{
var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
// Calculates SHA1 Hash for byte[]
Response.AddCacheHeader(content);
// Calculates SHA1 Hash for byte[]
Response.AddCacheHeader(content);
return File(content, "image/" + format);
return File(content, "image/" + format);
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
throw;
}
}
[HttpGet("chapter-info")]

View File

@ -9,6 +9,7 @@ using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -45,11 +46,26 @@ namespace API.Controllers
return Ok(series);
}
/// <summary>
/// Fetches a Series for a given Id
/// </summary>
/// <param name="seriesId">Series Id to fetch details for</param>
/// <returns></returns>
/// <exception cref="KavitaException">Throws an exception if the series Id does exist</exception>
[HttpGet("{seriesId}")]
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id));
try
{
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id));
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue fetching {SeriesId}", seriesId);
throw new KavitaException("This series does not exist");
}
}
[Authorize(Policy = "RequireAdminRole")]
@ -138,10 +154,21 @@ namespace API.Controllers
series.SortName = updateSeries.SortName.Trim();
series.Summary = updateSeries.Summary.Trim();
var needsRefreshMetadata = false;
if (!updateSeries.CoverImageLocked)
{
series.CoverImageLocked = false;
needsRefreshMetadata = true;
}
_unitOfWork.SeriesRepository.Update(series);
if (await _unitOfWork.CommitAsync())
{
if (needsRefreshMetadata)
{
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
}
return Ok();
}
@ -274,6 +301,12 @@ namespace API.Controllers
return BadRequest("Could not update metadata");
}
/// <summary>
/// Returns all Series grouped by the passed Collection Id with Pagination.
/// </summary>
/// <param name="collectionId">Collection Id to pull series from</param>
/// <param name="userParams">Pagination information</param>
/// <returns></returns>
[HttpGet("series-by-collection")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams)
{

View File

@ -0,0 +1,208 @@
using System;
using System.Threading.Tasks;
using API.DTOs.Uploads;
using API.Interfaces;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
/// <summary>
///
/// </summary>
[Authorize(Policy = "RequireAdminRole")]
public class UploadController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IImageService _imageService;
private readonly ILogger<UploadController> _logger;
private readonly ITaskScheduler _taskScheduler;
/// <inheritdoc />
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger, ITaskScheduler taskScheduler)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
_logger = logger;
_taskScheduler = taskScheduler;
}
/// <summary>
/// Replaces series cover image and locks it with a base64 encoded image
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("series")]
public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest("You must pass a url to use");
}
try
{
var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
if (bytes.Length > 0)
{
series.CoverImage = bytes;
series.CoverImageLocked = true;
_unitOfWork.SeriesRepository.Update(series);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Series");
}
/// <summary>
/// Replaces collection tag cover image and locks it with a base64 encoded image
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("collection")]
public async Task<ActionResult> UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest("You must pass a url to use");
}
try
{
var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
if (bytes.Length > 0)
{
tag.CoverImage = bytes;
tag.CoverImageLocked = true;
_unitOfWork.CollectionTagRepository.Update(tag);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Collection Tag");
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("chapter")]
public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest("You must pass a url to use");
}
try
{
var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
if (bytes.Length > 0)
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
chapter.CoverImage = bytes;
chapter.CoverImageLocked = true;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Chapter");
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>
/// <param name="uploadFileDto">Does not use Url property</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-chapter-lock")]
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
{
try
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
chapter.CoverImage = Array.Empty<byte>();
chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to resetting cover lock for Chapter");
}
}
}

View File

@ -2,6 +2,10 @@
namespace API.DTOs
{
/// <summary>
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
/// file (abstracted from type).
/// </summary>
public class ChapterDto
{
public int Id { get; init; }
@ -10,7 +14,7 @@ namespace API.DTOs
/// </summary>
public string Range { get; init; }
/// <summary>
/// Smallest number of the Range.
/// Smallest number of the Range.
/// </summary>
public string Number { get; init; }
/// <summary>
@ -22,7 +26,7 @@ namespace API.DTOs
/// </summary>
public bool IsSpecial { get; init; }
/// <summary>
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
/// </summary>
public string Title { get; init; }
/// <summary>
@ -33,6 +37,13 @@ namespace API.DTOs
/// Calculated at API time. Number of pages read for this Chapter for logged in user.
/// </summary>
public int PagesRead { get; set; }
/// <summary>
/// If the Cover Image is locked for this entity
/// </summary>
public bool CoverImageLocked { get; set; }
/// <summary>
/// Volume Id this Chapter belongs to
/// </summary>
public int VolumeId { get; init; }
}
}
}

View File

@ -6,5 +6,6 @@
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
public bool CoverImageLocked { get; set; }
}
}
}

View File

@ -12,6 +12,7 @@ namespace API.DTOs
public string SortName { get; init; }
public string Summary { get; init; }
public int Pages { get; init; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// Sum of pages read from linked Volumes. Calculated at API-time.
/// </summary>

View File

@ -10,5 +10,6 @@
public byte[] CoverImage { get; init; }
public int UserRating { get; set; }
public string UserReview { get; set; }
public bool CoverImageLocked { get; set; }
}
}
}

View File

@ -0,0 +1,14 @@
namespace API.DTOs.Uploads
{
public class UploadFileDto
{
/// <summary>
/// Id of the Entity
/// </summary>
public int Id { get; set; }
/// <summary>
/// Url of the file to download from (can be null)
/// </summary>
public string Url { get; set; }
}
}

View File

@ -0,0 +1,24 @@
using API.Entities;
using API.Interfaces.Repositories;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
namespace API.Data
{
public class ChapterRepository : IChapterRepository
{
private readonly DataContext _context;
public ChapterRepository(DataContext context)
{
_context = context;
}
public void Update(Chapter chapter)
{
_context.Entry(chapter).State = EntityState.Modified;
}
// TODO: Move over Chapter based queries here
}
}

View File

@ -0,0 +1,919 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210813010210_CoverImageLockFieldsPart1")]
partial class CoverImageLockFieldsPart1
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("BookScrollId")
.HasColumnType("TEXT");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
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.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.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.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.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
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("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.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("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.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class CoverImageLockFieldsPart1 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "CollectionTag",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "Series");
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "CollectionTag");
}
}
}

View File

@ -0,0 +1,922 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210814215831_CoverImageLockedFieldsPart2")]
partial class CoverImageLockedFieldsPart2
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<string>("BookScrollId")
.HasColumnType("TEXT");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
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.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.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.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.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
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("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.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("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.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class CoverImageLockedFieldsPart2 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "Chapter");
}
}
}

View File

@ -288,6 +288,9 @@ namespace API.Data.Migrations
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
@ -328,6 +331,9 @@ namespace API.Data.Migrations
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
@ -437,6 +443,9 @@ namespace API.Data.Migrations
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");

View File

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using Microsoft.AspNetCore.Identity;
@ -30,6 +31,7 @@ namespace API.Data
public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context);
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
public IFileRepository FileRepository => new FileRepository(_context);
public IChapterRepository ChapterRepository => new ChapterRepository(_context);
public bool Commit()
{

View File

@ -24,6 +24,7 @@ namespace API.Entities
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>
@ -33,7 +34,7 @@ namespace API.Entities
/// </summary>
public bool IsSpecial { get; set; }
/// <summary>
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
/// </summary>
public string Title { get; set; }
@ -52,7 +53,7 @@ namespace API.Entities
Title = (IsSpecial && info.Format == MangaFormat.Epub)
? info.Title
: Range;
}
}
}
}

View File

@ -16,12 +16,16 @@ namespace API.Entities
/// Visible title of the Tag
/// </summary>
public string Title { get; set; }
/// <summary>
/// Cover Image for the collection tag
/// </summary>
public byte[] CoverImage { get; set; }
/// <summary>
/// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations.
/// </summary>
public bool CoverImageLocked { get; set; }
/// <summary>
/// A description of the tag
/// </summary>
@ -35,9 +39,9 @@ namespace API.Entities
/// A promoted collection tag will allow all linked seriesMetadata's Series to show for all users.
/// </summary>
public bool Promoted { get; set; }
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; }
[ConcurrencyCheck]
public uint RowVersion { get; set; }
@ -47,4 +51,4 @@ namespace API.Entities
RowVersion++;
}
}
}
}

View File

@ -2,16 +2,36 @@
namespace API.Entities.Enums
{
/// <summary>
/// Represents the format of the file
/// </summary>
public enum MangaFormat
{
/// <summary>
/// Image file
/// See <see cref="Parser.Parser.ImageFileExtensions"/> for supported extensions
/// </summary>
[Description("Image")]
Image = 0,
/// <summary>
/// Archive based file
/// See <see cref="Parser.Parser.ArchiveFileExtensions"/> for supported extensions
/// </summary>
[Description("Archive")]
Archive = 1,
/// <summary>
/// Unknown. Not used.
/// </summary>
[Description("Unknown")]
Unknown = 2,
/// <summary>
/// EPUB File
/// </summary>
[Description("EPUB")]
Epub = 3,
/// <summary>
/// PDF File
/// </summary>
[Description("PDF")]
Pdf = 4
}

View File

@ -38,6 +38,10 @@ namespace API.Entities
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
/// <summary>
/// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations.
/// </summary>
public bool CoverImageLocked { get; set; }
/// <summary>
/// Sum of all Volume page counts
/// </summary>
public int Pages { get; set; }

View File

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using API.Interfaces.Repositories;
namespace API.Interfaces
{
@ -12,10 +13,11 @@ namespace API.Interfaces
IAppUserProgressRepository AppUserProgressRepository { get; }
ICollectionTagRepository CollectionTagRepository { get; }
IFileRepository FileRepository { get; }
IChapterRepository ChapterRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
bool Rollback();
Task<bool> RollbackAsync();
}
}
}

View File

@ -0,0 +1,9 @@
using API.Entities;
namespace API.Interfaces.Repositories
{
public interface IChapterRepository
{
void Update(Chapter chapter);
}
}

View File

@ -6,5 +6,17 @@ namespace API.Interfaces.Services
{
byte[] GetCoverImage(string path, bool createThumbnail = false);
string GetCoverFile(MangaFile file);
/// <summary>
/// Creates a Thumbnail version of an image
/// </summary>
/// <param name="path">Path to the image file</param>
/// <returns></returns>
public byte[] CreateThumbnail(string path);
/// <summary>
/// Creates a Thumbnail version of a base64 image
/// </summary>
/// <param name="encodedImage">base64 encoded image</param>
/// <returns></returns>
public byte[] CreateThumbnailFromBase64(string encodedImage);
}
}

View File

@ -158,9 +158,13 @@ namespace API.Services
_logger.LogInformation("Cache directory purged");
}
/// <summary>
/// Removes the cached files and folders for a set of chapterIds
/// </summary>
/// <param name="chapterIds"></param>
public void CleanupChapters(IEnumerable<int> chapterIds)
{
_logger.LogInformation("Running Cache cleanup on Volumes");
_logger.LogInformation("Running Cache cleanup on Chapters");
foreach (var chapter in chapterIds)
{
@ -182,7 +186,7 @@ namespace API.Services
/// <returns></returns>
private string GetCachePath(int chapterId)
{
return Path.GetFullPath(Path.Join(CacheDirectory, $"{chapterId}/"));
return Path.GetFullPath(Path.Join(DirectoryService.CacheDirectory, $"{chapterId}/"));
}
public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page)

View File

@ -51,8 +51,7 @@ namespace API.Services
{
if (createThumbnail)
{
using var thumbnail = Image.Thumbnail(path, MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
return CreateThumbnail(path);
}
using var img = Image.NewFromFile(path);
@ -67,5 +66,39 @@ namespace API.Services
return Array.Empty<byte>();
}
/// <inheritdoc />
public byte[] CreateThumbnail(string path)
{
try
{
using var thumbnail = Image.Thumbnail(path, MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
}
catch (Exception e)
{
_logger.LogError(e, "Error creating thumbnail from url");
}
return Array.Empty<byte>();
}
/// <inheritdoc />
public byte[] CreateThumbnailFromBase64(string encodedImage)
{
try
{
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
}
catch (Exception e)
{
_logger.LogError(e, "Error creating thumbnail from url");
}
return Array.Empty<byte>();
}
}
}

View File

@ -22,6 +22,9 @@ namespace API.Services
private readonly IBookService _bookService;
private readonly IImageService _imageService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
/// <summary>
/// Width of the Thumbnail generation
/// </summary>
public static readonly int ThumbnailWidth = 320; // 153w x 230h
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
@ -56,17 +59,26 @@ namespace API.Services
}
}
/// <summary>
/// Updates the metadata for a Chapter
/// </summary>
/// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Chapter chapter, bool forceUpdate)
{
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (ShouldFindCoverImage(chapter.CoverImage, forceUpdate) && firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
if (!chapter.CoverImageLocked && ShouldFindCoverImage(chapter.CoverImage, forceUpdate) && firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
{
chapter.Files ??= new List<MangaFile>();
chapter.CoverImage = GetCoverImage(firstFile);
}
}
/// <summary>
/// Updates the metadata for a Volume
/// </summary>
/// <param name="volume"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Volume volume, bool forceUpdate)
{
if (volume == null || !ShouldFindCoverImage(volume.CoverImage, forceUpdate)) return;
@ -74,25 +86,32 @@ namespace API.Services
volume.Chapters ??= new List<Chapter>();
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
if (firstChapter == null) return;
// Skip calculating Cover Image (I/O) if the chapter already has it set
if (firstChapter == null || ShouldFindCoverImage(firstChapter.CoverImage, forceUpdate))
if (!firstChapter.CoverImageLocked && ShouldFindCoverImage(firstChapter.CoverImage, forceUpdate))
{
// NOTE: Why do I do this? By the time this method gets executed, the chapter has already been calculated for
// Plus how can we have a volume without at least 1 chapter?
var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
{
volume.CoverImage = GetCoverImage(firstFile);
firstChapter.CoverImage = GetCoverImage(firstFile);
}
}
else
{
volume.CoverImage = firstChapter.CoverImage;
}
volume.CoverImage = firstChapter.CoverImage;
}
/// <summary>
/// Updates metadata for Series
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Series series, bool forceUpdate)
{
if (series == null) return;
if (ShouldFindCoverImage(series.CoverImage, forceUpdate))
if (!series.CoverImageLocked && ShouldFindCoverImage(series.CoverImage, forceUpdate))
{
series.Volumes ??= new List<Volume>();
var firstCover = series.Volumes.GetCoverImage(series.Format);
@ -140,12 +159,18 @@ namespace API.Services
}
/// <summary>
/// Refreshes Metatdata for a whole library
/// </summary>
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void RefreshMetadata(int libraryId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).GetAwaiter().GetResult();
// TODO: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used
// PERF: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used
_logger.LogInformation("Beginning metadata refresh of {LibraryName}", library.Name);
foreach (var series in library.Series)
{
@ -171,6 +196,11 @@ namespace API.Services
}
/// <summary>
/// Refreshes Metadata for a Series. Will always force updates.
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
public void RefreshMetadataForSeries(int libraryId, int seriesId)
{
var sw = Stopwatch.StartNew();

View File

@ -476,7 +476,7 @@ namespace API.Services.Tasks
};
}
default:
_logger.LogWarning("[Scanner] Ignoring {Filename}. Non-archives are not supported", info.Filename);
_logger.LogWarning("[Scanner] Ignoring {Filename}. File type is not supported", info.Filename);
break;
}

View File

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using API.Extensions;
@ -47,7 +48,9 @@ namespace API
services.AddIdentityServices(_config);
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" });
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Kavita API", Version = "v1" });
var filePath = Path.Combine(System.AppContext.BaseDirectory, "API.xml");
c.IncludeXmlComments(filePath);
});
services.AddResponseCompression(options =>
{

View File

@ -1,5 +1,8 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": "6b518972-3ce0-486d-bc55-740bf8308c77"
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
@ -41,7 +44,8 @@
],
"scripts": [
"node_modules/lazysizes/lazysizes.min.js",
"node_modules/lazysizes/plugins/rias/ls.rias.min.js"
"node_modules/lazysizes/plugins/rias/ls.rias.min.js",
"node_modules/lazysizes/plugins/attrchange/ls.attrchange.min.js"
]
},
"configurations": {

20349
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,7 @@
"ng-circle-progress": "^1.6.0",
"ng-lazyload-image": "^9.1.0",
"ng-sidebar": "^9.4.2",
"ngx-file-drop": "^11.1.0",
"ngx-toastr": "^13.2.1",
"rxjs": "~6.6.0",
"swiper": "^6.5.8",

View File

@ -1,53 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<form [formGroup]="collectionTagForm">
<div class="form-group">
<label for="summary">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
<div class="list-group" *ngIf="!isLoading">
<h6>Applies to Series</h6>
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let item of series; let i = index">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
</ul>
</div>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View File

@ -5,7 +5,8 @@ export interface Chapter {
range: string;
number: string;
files: Array<MangaFile>;
coverImage: string;
//coverImage: string;
coverImageLocked: boolean;
pages: number;
volumeId: number;
pagesRead: number; // Attached for the given user when requesting from API

View File

@ -2,6 +2,10 @@ export interface CollectionTag {
id: number;
title: string;
promoted: boolean;
/**
* This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
*/
coverImage: string;
coverImageLocked: boolean;
summary: string;
}

View File

@ -8,7 +8,8 @@ export interface Series {
localizedName: string;
sortName: string;
summary: string;
coverImage: string;
coverImage: string; // This is not passed from backend any longer. TODO: Remove this field
coverImageLocked: boolean;
volumes: Volume[];
pages: number; // Total pages in series
pagesRead: number; // Total pages the logged in user has read

View File

@ -104,6 +104,20 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: true
});
this.volumeActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: false
});
this.chapterActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: false
});
}
if (this.hasDownloadRole || this.isAdmin) {
@ -206,21 +220,5 @@ export class ActionFactoryService {
requiresAdmin: false
}
];
this.volumeActions.push({
action: Action.Info,
title: 'Info',
callback: this.dummyCallback,
requiresAdmin: false
});
this.chapterActions.push({
action: Action.Info,
title: 'Info',
callback: this.dummyCallback,
requiresAdmin: false
});
}
}

View File

@ -3,7 +3,7 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { BookmarksModalComponent } from '../_modals/bookmarks-modal/bookmarks-modal.component';
import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component';
import { Chapter } from '../_models/chapter';
import { Library } from '../_models/library';
import { Series } from '../_models/series';

View File

@ -16,14 +16,14 @@ export class CollectionTagService {
allTags() {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/').pipe(map(tags => {
tags.forEach(s => s.coverImage = this.imageService.getCollectionCoverImage(s.id));
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
return tags;
}));
}
search(query: string) {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => {
tags.forEach(s => s.coverImage = this.imageService.getCollectionCoverImage(s.id));
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
return tags;
}));
}

View File

@ -10,6 +10,7 @@ export class ImageService {
baseUrl = environment.apiUrl;
public placeholderImage = 'assets/images/image-placeholder-min.png';
public errorImage = 'assets/images/error-placeholder2-min.png';
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
constructor(private navSerivce: NavService) {
this.navSerivce.darkMode$.subscribe(res => {
@ -46,4 +47,17 @@ export class ImageService {
updateErroredImage(event: any) {
event.target.src = this.placeholderImage;
}
/**
* Used to refresh an existing loaded image (lazysizes). If random already attached, will append another number onto it.
* @param url Existing request url from ImageService only
* @returns Url with a random parameter attached
*/
randomize(url: string) {
const r = Math.random() * 100 + 1;
if (url.indexOf('&random') >= 0) {
return url + 1;
}
return url + '&random=' + r;
}
}

View File

@ -0,0 +1,43 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class UploadService {
private baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
/**
*
* @param seriesId Series to overwrite cover image for
* @param url A base64 encoded url
* @returns
*/
updateSeriesCoverImage(seriesId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url)});
}
updateCollectionCoverImage(tagId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)});
}
updateChapterCoverImage(chapterId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)});
}
resetChapterCoverLock(chapterId: number, ) {
return this.httpClient.post<number>(this.baseUrl + 'upload/reset-chapter-lock', {id: chapterId, url: ''});
}
_cleanBase64Url(url: string) {
if (url.startsWith('data')) {
url = url.split(',')[1];
}
return url;
}
}

View File

@ -37,7 +37,7 @@ export class LibraryAccessModalComponent implements OnInit {
}
close() {
this.modal.close(false);
this.modal.dismiss();
}
save() {

View File

@ -74,10 +74,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
openEditLibraryAccess(member: Member) {
const modalRef = this.modalService.open(LibraryAccessModalComponent);
modalRef.componentInstance.member = member;
modalRef.closed.subscribe(result => {
if (result) {
this.loadMembers();
}
modalRef.closed.subscribe(() => {
this.loadMembers();
});
}

View File

@ -3,7 +3,7 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';

View File

@ -7,7 +7,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeComponent } from './home/home.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
@ -20,7 +20,6 @@ import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { CarouselModule } from './carousel/carousel.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
@ -35,12 +34,9 @@ import { Dedupe as DedupeIntegration } from '@sentry/integrations';
import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { TypeaheadModule } from './typeahead/typeahead.module';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { LibraryCardComponent } from './library-card/library-card.component';
import { SeriesCardComponent } from './series-card/series-card.component';
import { InProgressComponent } from './in-progress/in-progress.component';
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
import { CardsModule } from './cards/cards.module';
let sentryProviders: any[] = [];
@ -98,16 +94,11 @@ if (environment.production) {
SeriesDetailComponent,
NotConnectedComponent, // Move into ExtrasModule
UserPreferencesComponent, // Move into SettingsModule
EditSeriesModalComponent,
ReviewSeriesModalComponent,
PersonBadgeComponent,
AllCollectionsComponent,
EditCollectionTagsComponent,
RecentlyAddedComponent,
LibraryCardComponent,
SeriesCardComponent,
InProgressComponent,
BookmarksModalComponent
],
imports: [
HttpClientModule,
@ -115,19 +106,23 @@ if (environment.production) {
AppRoutingModule,
BrowserAnimationsModule,
ReactiveFormsModule,
FormsModule, // EditCollection Modal
NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav
NgbTooltipModule, // Shared & SettingsModule
NgbRatingModule, // Series Detail
NgbCollapseModule, // Series Edit Modal
NgbNavModule, // Series Edit Modal
NgbNavModule,
NgbAccordionModule, // User Preferences
NgxSliderModule, // User Preference
NgbPaginationModule,
SharedModule,
CarouselModule,
TypeaheadModule,
FormsModule, // EditCollection Modal
CardsModule,
ToastrModule.forRoot({
positionClass: 'toast-bottom-right',
preventDuplicates: true,
@ -135,7 +130,6 @@ if (environment.production) {
countDuplicates: true,
autoDismiss: true
}),
],
providers: [
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},

View File

@ -7,7 +7,7 @@
<div class="modal-body">
<ul class="list-unstyled">
<li class="list-group-item">
<li class="list-group-item" *ngIf="bookmarks.length > 0">
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
</li>
<li class="list-group-item" *ngIf="bookmarks.length === 0">
@ -16,11 +16,11 @@
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) && bookmarks.length > 0">
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
<span *ngIf="isClearing" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Clear{{isClearing ? 'ing...' : ''}}</span>
</button>
<button type="button" class="btn btn-secondary" (click)="downloadBookmarks()" [disabled]="(isDownloading || isClearing) && bookmarks.length > 0">
<button type="button" class="btn btn-secondary" (click)="downloadBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
<span *ngIf="isDownloading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Download{{isDownloading ? 'ing...' : ''}}</span>
</button>

View File

@ -7,19 +7,18 @@
</button>
</div>
<div class="modal-body scrollable-modal">
<h4 *ngIf="isObjectVolume(data)">Information</h4>
<h4 *ngIf="utilityService.isVolume(data)">Information</h4>
<ng-container *ngIf="isObjectVolume(data) || isObjectChapter(data)">
<ng-container *ngIf="utilityService.isVolume(data) || utilityService.isChapter(data)">
<div class="row no-gutters">
<div class="col">
Id: {{data.id}}
</div>
<div class="col">
<!-- Special: {{(data?.isSpecial ? 'Yes' : 'No') || 'N/A'}} -->
</div>
</div>
<div class="row no-gutters">
<div class="col" *ngIf="isObjectVolume(data)">
<div class="col" *ngIf="utilityService.isVolume(data)">
Added: {{(data.created | date: 'MM/dd/yyyy') || '-'}}
</div>
<div class="col">
@ -28,10 +27,10 @@
</div>
</ng-container>
<h4 *ngIf="!isObjectChapter(data)">Chapters</h4>
<h4 *ngIf="!utilityService.isChapter(data)">Chapters</h4>
<ul class="list-unstyled">
<li class="media my-4" *ngFor="let chapter of chapters">
<img class="mr-3" style="width: 74px" src="{{imageService.getChapterCoverImage(chapter.id)}}">
<img class="mr-3" style="width: 74px" src="{{imageService.randomize(imageService.getChapterCoverImage(chapter.id))}}">
<div class="media-body">
<h5 class="mt-0 mb-1">
<span *ngIf="chapter.number !== '0'; else specialHeader">
@ -40,7 +39,7 @@
<ng-template #specialHeader>File(s)</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item"> <!-- .sort() -->
<li *ngFor="let file of chapter.files" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="col">
@ -58,6 +57,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-info" (click)="updateCover()">Update Cover</button>
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View File

@ -0,0 +1,4 @@
.scrollable-modal {
max-height: 90vh; // 600px
overflow: auto;
}

View File

@ -0,0 +1,92 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Volume } from 'src/app/_models/volume';
import { ImageService } from 'src/app/_services/image.service';
import { UploadService } from 'src/app/_services/upload.service';
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
@Component({
selector: 'app-card-details-modal',
templateUrl: './card-details-modal.component.html',
styleUrls: ['./card-details-modal.component.scss']
})
export class CardDetailsModalComponent implements OnInit {
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() data!: any; // Volume | Chapter
isChapter = false;
chapters: Chapter[] = [];
seriesVolumes: any[] = [];
isLoadingVolumes = false;
formatKeys = Object.keys(MangaFormat);
/**
* If a cover image update occured.
*/
coverImageUpdate: boolean = false;
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.data);
if (this.isChapter) {
this.chapters.push(this.data);
} else if (!this.isChapter) {
this.chapters.push(...this.data?.chapters);
}
this.chapters.sort(this.utilityService.sortChapters);
// Try to show an approximation of the reading order for files
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
}
close() {
this.modal.close({coverImageUpdate: this.coverImageUpdate});
}
formatChapterNumber(chapter: Chapter) {
if (chapter.number === '0') {
return '1';
}
return chapter.number;
}
updateCover() {
const modalRef = this.modalService.open(ChangeCoverImageModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
if (this.utilityService.isChapter(this.data)) {
const chapter = this.utilityService.asChapter(this.data)
modalRef.componentInstance.chapter = chapter;
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : 'Chapter ') + chapter.range + '\'s Cover';
} else {
const volume = this.utilityService.asVolume(this.data);
const chapters = volume.chapters;
if (chapters && chapters.length > 0) {
modalRef.componentInstance.chapter = chapters[0];
modalRef.componentInstance.title = 'Select Volume ' + volume.number + '\'s Cover';
}
}
modalRef.closed.subscribe((closeResult: {success: boolean, chapter: Chapter, coverImageUpdate: boolean}) => {
if (closeResult.success) {
this.coverImageUpdate = closeResult.coverImageUpdate;
if (!this.coverImageUpdate) {
this.uploadService.resetChapterCoverLock(closeResult.chapter.id).subscribe(() => {
this.toastr.info('Please refresh in a bit for the cover image to be reflected.');
});
}
}
});
}
}

View File

@ -0,0 +1,11 @@
<div class="modal-header">{{title}}</div>
<div class="modal-body scrollable-modal">
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="cancel()">Cancel</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="loading">Save</button>
</div>

View File

@ -0,0 +1,65 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Chapter } from 'src/app/_models/chapter';
import { ImageService } from 'src/app/_services/image.service';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-change-cover-image-modal',
templateUrl: './change-cover-image-modal.component.html',
styleUrls: ['./change-cover-image-modal.component.scss']
})
export class ChangeCoverImageModalComponent implements OnInit {
@Input() chapter!: Chapter;
@Input() title: string = '';
selectedCover: string = '';
imageUrls: Array<string> = [];
coverImageIndex: number = 0;
coverImageLocked: boolean = false;
loading: boolean = false;
constructor(private imageService: ImageService, private uploadService: UploadService, public modal: NgbActiveModal) { }
ngOnInit(): void {
// Randomization isn't needed as this is only the chooser
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
}
cancel() {
this.modal.close({success: false, coverImageUpdate: false})
}
save() {
this.loading = true;
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
}
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
this.loading = false;
}, err => this.loading = false);
} else {
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
}
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
this.chapter.coverImageLocked = false;
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
}
}

View File

@ -0,0 +1,72 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0]}}</a>
<ng-template ngbNavContent>
<form [formGroup]="collectionTagForm">
<div class="form-group">
<label for="summary">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
<div class="list-group" *ngIf="!isLoading">
<h6>Applies to Series</h6>
<div class="form-check">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let item of series; let i = index">
<div class="form-check">
<input id="series-{{i}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
</div>
</li>
</ul>
</div>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]">
<a ngbNavLink>{{tabs[1]}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
</div>

View File

@ -9,8 +9,10 @@ import { CollectionTag } from 'src/app/_models/collection-tag';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-edit-collection-tags',
@ -28,11 +30,15 @@ export class EditCollectionTagsComponent implements OnInit {
selectAll: boolean = true;
libraryNames!: any;
collectionTagForm!: FormGroup;
tabs = ['General', 'Cover Image'];
active = this.tabs[0];
imageUrls: Array<string> = [];
selectedCover: string = '';
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService) { }
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService) { }
ngOnInit(): void {
if (this.pagination == undefined) {
@ -40,7 +46,11 @@ export class EditCollectionTagsComponent implements OnInit {
}
this.collectionTagForm = new FormGroup({
summary: new FormControl(this.tag.summary, []),
coverImageLocked: new FormControl(this.tag.coverImageLocked, []),
coverImageIndex: new FormControl(0, []),
});
this.imageUrls.push(this.imageService.randomize(this.imageService.getCollectionCoverImage(this.tag.id)));
this.loadSeries();
}
@ -99,17 +109,27 @@ export class EditCollectionTagsComponent implements OnInit {
}
async save() {
const selectedIndex = this.collectionTagForm.get('coverImageIndex')?.value || 0;
const unselectedIds = this.selections.unselected().map(s => s.id);
const tag: CollectionTag = {...this.tag};
tag.summary = this.collectionTagForm.get('summary')?.value;
tag.coverImageLocked = this.collectionTagForm.get('coverImageLocked')?.value;
if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) {
return;
}
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id)).subscribe(() => {
const apis = [this.collectionService.updateTag(this.tag),
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id))
];
if (selectedIndex > 0) {
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover))
}
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, coverImageUpdated: selectedIndex > 0});
this.toastr.success('Tag updated');
this.modal.close(true);
});
}
@ -118,4 +138,20 @@ export class EditCollectionTagsComponent implements OnInit {
return (selected.length != this.series.length && selected.length != 0);
}
updateSelectedIndex(index: number) {
this.collectionTagForm.patchValue({
coverImageIndex: index
});
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.collectionTagForm.patchValue({
coverImageLocked: false
});
}
}

View File

@ -89,8 +89,10 @@
<li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent>
<p>Not Yet implemented</p>
<img src="{{imageService.getSeriesCoverImage(series.id)}}">
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="tabs[3]">

View File

@ -0,0 +1,4 @@
.scrollable-modal {
max-height: 90vh; // 600px
overflow: auto;
}

View File

@ -13,6 +13,7 @@ import { CollectionTagService } from 'src/app/_services/collection-tag.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-edit-series-modal',
@ -36,6 +37,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
settings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
tags: CollectionTag[] = [];
metadata!: SeriesMetadata;
imageUrls: Array<string> = [];
/**
* Selected Cover for uploading
*/
selectedCover: string = '';
constructor(public modal: NgbActiveModal,
private seriesService: SeriesService,
@ -43,9 +49,15 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
private fb: FormBuilder,
public imageService: ImageService,
private libraryService: LibraryService,
private collectionService: CollectionTagService) { }
private collectionService: CollectionTagService,
private uploadService: UploadService) { }
ngOnInit(): void {
// this.imageUrls.push({
// imageUrl: this.imageService.getSeriesCoverImage(this.series.id),
// source: 'Url'
// });
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
this.libraryName = names[this.series.libraryId];
@ -67,7 +79,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
author: new FormControl('', []),
artist: new FormControl('', []),
coverImageIndex: new FormControl(0, [])
coverImageIndex: new FormControl(0, []),
coverImageLocked: new FormControl(this.series.coverImageLocked, [])
});
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
@ -107,7 +120,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.settings.addIfNonExisting = true;
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter);
this.settings.addTransformFn = ((title: string) => {
return {id: 0, title: title, promoted: false, coverImage: '', summary: '' };
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
});
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
const f = filter.toLowerCase();
@ -131,13 +144,19 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
}
save() {
// TODO: In future (once locking or metadata implemented), do a converstion to updateSeriesDto
forkJoin([
this.seriesService.updateSeries(this.editSeriesForm.value),
const model = this.editSeriesForm.value;
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
const apis = [
this.seriesService.updateSeries(model),
this.seriesService.updateMetadata(this.metadata, this.tags)
]).subscribe(results => {
this.modal.close({success: true, series: this.editSeriesForm.value});
];
if (selectedIndex > 0) {
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
}
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});
});
}
@ -145,4 +164,20 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.tags = tags;
}
updateSelectedIndex(index: number) {
this.editSeriesForm.patchValue({
coverImageIndex: index
});
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.editSeriesForm.patchValue({
coverImageLocked: false
});
}
}

View File

@ -1,8 +1,8 @@
<div class="card">
<div class="overlay" (click)="handleClick()">
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.errorImage" [attr.data-src]="imageUrl"
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageService.errorImage" [attr.data-src]="imageUrl"
aria-hidden="true" height="230px" width="158px">
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>

View File

@ -2,6 +2,9 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angu
import { ToastrService } from 'ngx-toastr';
import { asyncScheduler, Observable, Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators';
import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { MangaFormat } from 'src/app/_models/manga-format';
@ -10,9 +13,6 @@ import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { Download } from '../_models/download';
import { DownloadService } from '../_services/download.service';
import { UtilityService } from '../_services/utility.service';
@Component({
selector: 'app-card-item',
@ -44,7 +44,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(public imageSerivce: ImageService, private libraryService: LibraryService,
constructor(public imageService: ImageService, private libraryService: LibraryService,
public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService) {}
@ -62,8 +62,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
});
}
this.format = (this.entity as Series).format;
}
ngOnDestroy() {

View File

@ -0,0 +1,77 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesCardComponent } from './series-card/series-card.component';
import { LibraryCardComponent } from './library-card/library-card.component';
import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { NgxFileDropModule } from 'ngx-file-drop';
import { CardItemComponent } from './card-item/card-item.component';
import { SharedModule } from '../shared/shared.module';
import { RouterModule } from '@angular/router';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { BrowserModule } from '@angular/platform-browser';
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
@NgModule({
declarations: [
CardItemComponent,
SeriesCardComponent,
LibraryCardComponent,
CoverImageChooserComponent,
EditSeriesModalComponent,
EditCollectionTagsComponent,
ChangeCoverImageModalComponent,
BookmarksModalComponent,
CardActionablesComponent,
CardDetailLayoutComponent,
CardDetailsModalComponent
],
imports: [
CommonModule,
BrowserModule,
RouterModule,
ReactiveFormsModule,
FormsModule, // EditCollectionsModal
SharedModule,
TypeaheadModule,
NgbNavModule,
NgbTooltipModule, // Card item
//NgbAccordionModule,
NgbCollapseModule,
NgbNavModule, //Series Detail
LazyLoadImageModule,
NgbPaginationModule, // CardDetailLayoutComponent
NgbDropdownModule,
NgbProgressbarModule,
NgxFileDropModule, // Cover Chooser
],
exports: [
CardItemComponent,
SeriesCardComponent,
LibraryCardComponent,
SeriesCardComponent,
LibraryCardComponent,
CoverImageChooserComponent,
EditSeriesModalComponent,
EditCollectionTagsComponent,
ChangeCoverImageModalComponent,
BookmarksModalComponent,
CardActionablesComponent,
CardDetailLayoutComponent,
CardDetailsModalComponent
]
})
export class CardsModule { }

View File

@ -0,0 +1,64 @@
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
<form [formGroup]="form">
<ngx-file-drop (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" accept=".png,.jpg,.jpeg" [directory]="false" dropZoneClassName="file-upload" contentClassName="file-upload-zone" [directory]="false">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div class="row no-gutters mt-3 pb-3" *ngIf="mode === 'all'">
<div class="mx-auto">
<div class="row no-gutters mb-3">
<i class="fa fa-file-upload mx-auto" style="font-size: 24px;" aria-hidden="true"></i>
</div>
<div class="row no-gutters">
<div class="mx-auto">
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="mode = 'url'; setupEnterHandler()"><span class="phone-hidden">Enter a </span>Url</a>
<span class="col" style="padding-right:0px"></span>
<span class="col" style="padding-right:0px" href="javascript:void(0)">Drag and drop</span>
<span class="col" style="padding-right:0px"></span>
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="openFileSelector()">Upload<span class="phone-hidden"> an image</span></a>
</div>
</div>
</div>
</div>
<ng-container *ngIf="mode === 'url'">
<div class="row no-gutters mt-3 pb-3 ml-md-2 mr-md-2">
<div class="input-group col-md-10 mr-md-2" style="width: 100%">
<div class="input-group-prepend">
<label class="input-group-text" for="load-image">Url</label>
</div>
<input type="text" autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
Load
</button>
</div>
</div>
<button class="col btn btn-secondary" href="javascript:void(0)" (click)="mode = 'all'" aria-label="Back">
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>&nbsp;
<span class="phone-hidden">Back</span>
</button>
</div>
</ng-container>
</ng-template>
</ngx-file-drop>
<ng-template>
</ng-template>
</form>
<div class="row no-gutters chooser" style="padding-top: 10px">
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
<img class="card-img-top" [src]="url" aria-hidden="true" height="230px" width="158px" (error)="imageService.updateErroredImage($event)">
</div>
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
<img class="card-img-top" title="Reset Cover Image" [src]="imageService.resetCoverImage" aria-hidden="true" height="230px" width="158px">
</div>
</div>
</div>

View File

@ -0,0 +1,36 @@
@import '../../../theme/colors';
$image-height: 230px;
$image-width: 160px;
.card-img-top {
width: $image-width;
height: $image-height;
max-height: $image-height;
}
.image-card {
margin: 10px;
cursor: pointer;
}
.selected {
outline: 5px solid $primary-color;
outline-width: medium;
outline-offset: -1px;
}
ngx-file-drop ::ng-deep > div {
// styling for the outer drop box
width: 100%;
border: 2px solid $primary-color;
border-radius: 5px;
height: 100px;
margin: auto;
> div {
// styling for the inner box (template)
width: 100%;
display: inline-block;
}
}

View File

@ -0,0 +1,187 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgxFileDropEntry, FileSystemFileEntry } from 'ngx-file-drop';
import { fromEvent, Subject } from 'rxjs';
import { takeWhile } from 'rxjs/operators';
import { ToastrService } from 'ngx-toastr';
import { ImageService } from 'src/app/_services/image.service';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
@Component({
selector: 'app-cover-image-chooser',
templateUrl: './cover-image-chooser.component.html',
styleUrls: ['./cover-image-chooser.component.scss']
})
export class CoverImageChooserComponent implements OnInit, OnDestroy {
@Input() imageUrls: Array<string> = [];
@Output() imageUrlsChange: EventEmitter<Array<string>> = new EventEmitter<Array<string>>();
/**
* Should the control give the ability to select an image that emits the reset status for cover image
*/
@Input() showReset: boolean = false;
@Output() resetClicked: EventEmitter<void> = new EventEmitter<void>();
/**
* Emits the selected index. Used usually to check if something other than the default image was selected.
*/
@Output() imageSelected: EventEmitter<number> = new EventEmitter<number>();
/**
* Emits a base64 encoded image
*/
@Output() selectedBase64Url: EventEmitter<string> = new EventEmitter<string>();
selectedIndex: number = 0;
form!: FormGroup;
files: NgxFileDropEntry[] = [];
mode: 'file' | 'url' | 'all' = 'all';
private readonly onDestroy = new Subject<void>();
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService) { }
ngOnInit(): void {
this.form = this.fb.group({
coverImageUrl: new FormControl('', [])
});
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
getBase64Image(img: HTMLImageElement) {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d", {alpha: false});
if (!ctx) {
return '';
}
ctx.drawImage(img, 0, 0);
var dataURL = canvas.toDataURL("image/png");
return dataURL;
}
selectImage(index: number) {
if (this.selectedIndex === index) { return; }
this.selectedIndex = index;
this.imageSelected.emit(this.selectedIndex);
const selector = `.chooser img[src="${this.imageUrls[this.selectedIndex]}"]`;
const elem = document.querySelector(selector) || document.querySelectorAll('.chooser img.card-img-top')[this.selectedIndex];
if (elem) {
const imageElem = <HTMLImageElement>elem;
if (imageElem.src.startsWith('data')) {
this.selectedBase64Url.emit(imageElem.src);
return;
}
const image = this.getBase64Image(imageElem);
if (image != '') {
this.selectedBase64Url.emit(image);
}
}
}
loadImage() {
const url = this.form.get('coverImageUrl')?.value.trim();
if (url && url != '') {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = this.form.get('coverImageUrl')?.value;
img.onload = (e) => this.handleUrlImageAdd(e);
img.onerror = (e) => {
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
this.form.get('coverImageUrl')?.setValue('');
}
this.form.get('coverImageUrl')?.setValue('');
}
}
public dropped(files: NgxFileDropEntry[]) {
this.files = files;
for (const droppedFile of files) {
// Is it a file?
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file((file: File) => {
const reader = new FileReader();
reader.onload = (e) => this.handleFileImageAdd(e);
reader.readAsDataURL(file);
});
}
}
}
handleFileImageAdd(e: any) {
if (e.target == null) return;
this.imageUrls.push(e.target.result);
this.imageUrlsChange.emit(this.imageUrls);
this.selectedIndex += 1;
this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image
this.selectedBase64Url.emit(e.target.result);
}
handleUrlImageAdd(e: any) {
console.log(e);
if (e.path === null || e.path.length === 0) return;
const url = this.getBase64Image(e.path[0]);
this.imageUrls.push(url);
this.imageUrlsChange.emit(this.imageUrls);
setTimeout(() => {
// Auto select newly uploaded image and tell parent of new base64 url
this.selectImage(this.selectedIndex + 1)
});
}
public fileOver(event: any){
}
public fileLeave(event: any){
}
reset() {
this.resetClicked.emit();
this.selectedIndex = -1;
}
setupEnterHandler() {
setTimeout(() => {
const elem = document.querySelector('input[id="load-image"]');
if (elem == null) return;
fromEvent(elem, 'keydown')
.pipe(takeWhile(() => this.mode === 'url')).subscribe((event) => {
const evt = <KeyboardEvent>event;
switch(evt.key) {
case KEY_CODES.ENTER:
{
this.loadImage();
break;
}
case KEY_CODES.ESC_KEY:
this.mode = 'all';
event.stopPropagation();
break;
default:
break;
}
});
});
}
}

View File

@ -0,0 +1,3 @@
<ng-container *ngIf="data !== undefined">
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl" [entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"></app-card-item>
</ng-container>

View File

@ -3,14 +3,14 @@ import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { EditSeriesModalComponent } from 'src/app/_modals/edit-series-modal/edit-series-modal.component';
import { Series } from 'src/app/_models/series';
import { AccountService } from 'src/app/_services/account.service';
import { ImageService } from 'src/app/_services/image.service';
import { ActionFactoryService, Action, ActionItem } from 'src/app/_services/action-factory.service';
import { SeriesService } from 'src/app/_services/series.service';
import { ConfirmService } from '../shared/confirm.service';
import { ActionService } from '../_services/action.service';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { ActionService } from 'src/app/_services/action.service';
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
@Component({
selector: 'app-series-card',
@ -27,6 +27,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
isAdmin = false;
actions: ActionItem<Series>[] = [];
imageUrl: string = '';
constructor(private accountService: AccountService, private router: Router,
private seriesService: SeriesService, private toastr: ToastrService,
@ -42,11 +43,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
ngOnInit(): void {
if (this.data) {
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
}
}
ngOnChanges(changes: any) {
if (this.data) {
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
}
}
@ -79,11 +84,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
}
openEditModal(data: Series) {
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg', scrollable: true });
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' });
modalRef.componentInstance.series = data;
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series}) => {
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
window.scrollTo(0, 0);
if (closeResult.success) {
if (closeResult.coverImageUpdate) {
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(closeResult.series.id));
console.log('image url: ', this.imageUrl);
}
this.seriesService.getSeries(data.id).subscribe(series => {
this.data = series;
this.reload.emit(true);

View File

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router, ActivatedRoute } from '@angular/router';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, SeriesFilter, mangaFormatFilters } from '../_models/series-filter';

View File

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { Library } from '../_models/library';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';

View File

@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter';
import { Library } from '../_models/library';
@ -13,6 +13,7 @@ import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { CollectionTagService } from '../_services/collection-tag.service';
import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service';
import { SeriesService } from '../_services/series.service';
@ -41,7 +42,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private actionFactoryService: ActionFactoryService,
private collectionService: CollectionTagService, private router: Router,
private modalService: NgbModal, private titleService: Title) { }
private modalService: NgbModal, private titleService: Title, public imageService: ImageService) { }
ngOnInit(): void {
this.titleService.setTitle('Kavita - Dashboard');
@ -123,6 +124,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
if (reloadNeeded) {
// Reload tags
this.reloadTags();
collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
}
});
break;

View File

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';

View File

@ -1,3 +0,0 @@
<ng-container *ngIf="data !== undefined">
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageService.getSeriesCoverImage(data.id)" [entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"></app-card-item>
</ng-container>

View File

@ -1,7 +1,7 @@
<div class="container-fluid" *ngIf="series !== undefined" style="padding-top: 10px">
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6">
<img class="poster lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageService.getSeriesCoverImage(series.id)"
<img class="poster lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="seriesImage"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true">
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
@ -118,12 +118,12 @@
<div class="row">
<div *ngFor="let volume of volumes">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions"></app-card-item>
</div>
<div *ngFor="let chapter of chapters">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
</div>
</div>

View File

@ -5,13 +5,13 @@ import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { asyncScheduler } from 'rxjs';
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service';
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
import { CardDetailsModalComponent } from '../shared/_modals/card-details-modal/card-details-modal.component';
import { DownloadService } from '../shared/_services/download.service';
import { UtilityService } from '../shared/_services/utility.service';
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
import { Chapter } from '../_models/chapter';
import { LibraryType } from '../_models/library';
@ -61,9 +61,17 @@ export class SeriesDetailComponent implements OnInit {
userReview: string = '';
libraryType: LibraryType = LibraryType.Manga;
seriesMetadata: SeriesMetadata | null = null;
/**
* Poster image for the Series
*/
seriesImage: string = '';
downloadInProgress: boolean = false;
/**
* Tricks the cover images for volume/chapter cards to update after we update one of them
*/
coverImageOffset: number = 0;
/**
* If an action is currently being done, don't let the user kick off another action
*/
@ -116,6 +124,7 @@ export class SeriesDetailComponent implements OnInit {
const seriesId = parseInt(routeId, 10);
this.libraryId = parseInt(libraryId, 10);
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
this.loadSeriesMetadata(seriesId);
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
@ -169,7 +178,7 @@ export class SeriesDetailComponent implements OnInit {
case(Action.MarkAsUnread):
this.markAsUnread(volume);
break;
case(Action.Info):
case(Action.Edit):
this.openViewInfo(volume);
break;
default:
@ -185,7 +194,7 @@ export class SeriesDetailComponent implements OnInit {
case(Action.MarkAsUnread):
this.markChapterAsUnread(chapter);
break;
case(Action.Info):
case(Action.Edit):
this.openViewInfo(chapter);
break;
default:
@ -226,6 +235,7 @@ export class SeriesDetailComponent implements OnInit {
}
loadSeries(seriesId: number) {
this.coverImageOffset = 0;
this.seriesService.getMetadata(seriesId).subscribe(metadata => {
this.seriesMetadata = metadata;
});
@ -376,20 +386,29 @@ export class SeriesDetailComponent implements OnInit {
}
openViewInfo(data: Volume | Chapter) {
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg', scrollable: true });
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' }); // , scrollable: true (these don't work well on mobile)
modalRef.componentInstance.data = data;
modalRef.componentInstance.parentName = this.series?.name;
modalRef.componentInstance.seriesId = this.series?.id;
modalRef.closed.subscribe((result: {coverImageUpdate: boolean}) => {
if (result.coverImageUpdate) {
this.coverImageOffset += 1;
}
});
}
openEditSeriesModal() {
const modalRef = this.modalService.open(EditSeriesModalComponent, { scrollable: true, size: 'lg', windowClass: 'scrollable-modal' });
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
modalRef.componentInstance.series = this.series;
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series}) => {
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
window.scrollTo(0, 0);
if (closeResult.success) {
this.loadSeries(this.series.id);
this.loadSeriesMetadata(this.series.id);
if (closeResult.coverImageUpdate) {
// Random triggers a load change without any problems with API
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id));
}
}
});
}

View File

@ -1,3 +0,0 @@
.scrollable-modal {
max-height: 600px;
}

View File

@ -1,65 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Volume } from 'src/app/_models/volume';
import { ImageService } from 'src/app/_services/image.service';
import { UtilityService } from '../../_services/utility.service';
@Component({
selector: 'app-card-details-modal',
templateUrl: './card-details-modal.component.html',
styleUrls: ['./card-details-modal.component.scss']
})
export class CardDetailsModalComponent implements OnInit {
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() data!: any; // Volume | Chapter
isChapter = false;
chapters: Chapter[] = [];
seriesVolumes: any[] = [];
isLoadingVolumes = false;
formatKeys = Object.keys(MangaFormat);
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService) { }
ngOnInit(): void {
this.isChapter = this.isObjectChapter(this.data);
if (this.isChapter) {
this.chapters.push(this.data);
} else if (!this.isChapter) {
this.chapters.push(...this.data?.chapters);
}
this.chapters.sort(this.utilityService.sortChapters);
// Try to show an approximation of the reading order for files
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
}
isObjectChapter(object: any): object is Chapter {
return ('files' in object);
}
isObjectVolume(object: any): object is Volume {
return !('originalName' in object) && !('files' in object);
}
close() {
this.modal.close();
}
formatChapterNumber(chapter: Chapter) {
if (chapter.number === '0') {
return '1';
}
return chapter.number;
}
}

View File

@ -11,7 +11,7 @@ import { Observable } from 'rxjs';
import { SAVER, Saver } from '../_providers/saver.provider';
import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { debounce, debounceTime, map, take } from 'rxjs/operators';
import { debounceTime } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
@ -40,10 +40,6 @@ export class DownloadService {
}
downloadLogs() {
// this.httpClient.get(this.baseUrl + 'server/logs', {observe: 'response', responseType: 'blob' as 'text'}).subscribe(resp => {
// this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, 'logs'));
// });
return this.httpClient.get(this.baseUrl + 'server/logs',
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => {
@ -63,7 +59,7 @@ export class DownloadService {
downloadChapter(chapter: Chapter) {
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => {
).pipe(debounceTime(300), download((blob, filename) => { //NOTE: DO I need debounceTime since I have throttleTime()?
this.save(blob, filename)
}));
}
@ -88,30 +84,6 @@ export class DownloadService {
}));
}
private preformSave(res: string, filename: string) {
const blob = new Blob([res], {type: 'text/plain;charset=utf-8'});
saveAs(blob, filename);
this.toastr.success('File downloaded successfully: ' + filename);
}
/**
* Attempts to parse out the filename from Content-Disposition header.
* If it fails, will default to defaultName and add the correct extension. If no extension is found in header, will use zip.
* @param headers
* @param defaultName
* @returns
*/
private getFilenameFromHeader(headers: HttpHeaders, defaultName: string) {
const tokens = (headers.get('content-disposition') || '').split(';');
let filename = tokens[1].replace('filename=', '').replace(/"/ig, '').trim();
if (filename.startsWith('download_') || filename.startsWith('kavita_download_')) {
const ext = filename.substring(filename.lastIndexOf('.'), filename.length);
return defaultName + ext;
}
return filename;
}
/**
* Format bytes as human-readable text.
*

View File

@ -1,19 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { CardItemComponent } from './card-item/card-item.component';
import { NgbCollapseModule, NgbDropdownModule, NgbPaginationModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { SafeHtmlPipe } from './safe-html.pipe';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { RegisterMemberComponent } from '../register-member/register-member.component';
import { ReadMoreComponent } from './read-more/read-more.component';
import { RouterModule } from '@angular/router';
import { DrawerComponent } from './drawer/drawer.component';
import { TagBadgeComponent } from './tag-badge/tag-badge.component';
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
import { A11yClickDirective } from './a11y-click.directive';
import { SeriesFormatComponent } from './series-format/series-format.component';
@ -25,45 +20,40 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
@NgModule({
declarations: [
RegisterMemberComponent,
CardItemComponent,
CardDetailsModalComponent,
ConfirmDialogComponent,
SafeHtmlPipe,
CardActionablesComponent,
ReadMoreComponent,
DrawerComponent,
TagBadgeComponent,
CardDetailLayoutComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent,
UpdateNotificationModalComponent,
CircularLoaderComponent
CircularLoaderComponent,
],
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
NgbDropdownModule,
NgbProgressbarModule,
NgbTooltipModule,
//NgbDropdownModule,
//NgbProgressbarModule,
//NgbTooltipModule,
NgbCollapseModule,
LazyLoadImageModule,
NgbPaginationModule, // CardDetailLayoutComponent
NgCircleProgressModule.forRoot()
//LazyLoadImageModule,
NgCircleProgressModule.forRoot(),
],
exports: [
RegisterMemberComponent,
CardItemComponent,
SafeHtmlPipe,
CardActionablesComponent,
ReadMoreComponent,
DrawerComponent,
TagBadgeComponent,
CardDetailLayoutComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent,
SeriesFormatComponent,
TagBadgeComponent,
CircularLoaderComponent,
],
providers: [{provide: SAVER, useFactory: getSaver}]
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -1,4 +1,4 @@
$primary-color: #4ac694; //#cc7b19;
$primary-color: #4ac694; //(74,198,148)
$error-color: #ff4136; // #bb2929 good color for contrast rating
$theme-colors: (