mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Metadata Editing from the UI! (#1135)
* Added the skeleton code for layout, hooked up Age Rating, Publication Status, and Tags * Tweaked message of Scan service to Finished scan of to better indicate the total scan time * Hooked in foundation for person typeaheads * Fixed people not populating typeaheads on load * For manga/comics, when parsing, set the SeriesSort from ComicInfo if it exists. * Implemented the ability to override and create new genre tags. Code is ready to flush out the rest. * Ability to update metadata from the UI is hooked up. Next is locking. * Updated typeahead to allow for non-multiple usage. Implemented ability to update Language tag in Series Metadata. * Fixed a bug in GetContinuePoint for a case where we have Volumes, Loose Leaf chapters and no read progress. * Added ETag headers on Images to allow for better caching (bookmarks and images in manga reader) * Built out UI code to show locked indication to user * Implemented Series locking and refactored a lot of styles in typeahead to make the lock setting work, plus misc cleanup. * Added locked properties to dtos. Updated typeahead loading indicator to not interfere with close button if present * Hooked up locking flags in UI * Integrated regular field locking/unlocking * Removed some old code * Prevent input group from wrapping * Implemented some basic layout for metadata on volume/chapter card modal. Refactored out all metadata from Chapter object in terms of UI and put into a separate call to ensure speedy delivery and simplicity of code. * Refactored code to hide covers section if not an admin * Implemented ability to modify a chapter/volume cover from the detail modal * Removed a few variables and change cover image modal * Added bookmark to single chapter view * Put a temp fix in for a ngb v12 z-index bug (reported). Bumped ngb to 12.0 stable and fixed some small rendering bugs * loading buttons ftw * Lots of cleanup, looks like the story is finished * Changed action name from Info to Details * Style tweaks * Fixed an issue where Summary would assume it's locked due to a subscription firing on setting the model * Fixed some misc bugs * Code smells Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
47a92a2e01
commit
ba77954d5c
@ -168,6 +168,7 @@ namespace API.Tests.Parser
|
||||
[InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")]
|
||||
[InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")]
|
||||
[InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")]
|
||||
[InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")]
|
||||
public void ParseSeriesTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
|
||||
|
@ -1343,6 +1343,48 @@ public class ReaderServiceTests
|
||||
Assert.Equal("31", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes()
|
||||
{
|
||||
_context.Series.Add(new Series()
|
||||
{
|
||||
Name = "Test",
|
||||
Library = new Library() {
|
||||
Name = "Test LIb",
|
||||
Type = LibraryType.Manga,
|
||||
},
|
||||
Volumes = new List<Volume>()
|
||||
{
|
||||
EntityFactory.CreateVolume("0", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("230", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("231", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("1", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
|
||||
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
EntityFactory.CreateVolume("2", new List<Chapter>()
|
||||
{
|
||||
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007"
|
||||
});
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>());
|
||||
var nextChapter = await readerService.GetContinuePoint(1, 1);
|
||||
|
||||
Assert.Equal("1", nextChapter.Range);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead()
|
||||
{
|
||||
|
@ -106,6 +106,8 @@ namespace API.Controllers
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName));
|
||||
var format = Path.GetExtension(file.FullName).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(file.FullName);
|
||||
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
|
||||
}
|
||||
}
|
||||
|
@ -123,18 +123,30 @@ public class MetadataController : BaseApiController
|
||||
public async Task<ActionResult<IList<LanguageDto>>> GetAllLanguages(string? libraryIds)
|
||||
{
|
||||
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
|
||||
if (ids != null && ids.Count > 0)
|
||||
if (ids is {Count: > 0})
|
||||
{
|
||||
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
|
||||
}
|
||||
|
||||
var englishTag = CultureInfo.GetCultureInfo("en");
|
||||
return Ok(new List<LanguageDto>()
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Title = CultureInfo.GetCultureInfo("en").DisplayName,
|
||||
IsoCode = "en"
|
||||
Title = englishTag.DisplayName,
|
||||
IsoCode = englishTag.IetfLanguageTag
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("all-languages")]
|
||||
public IEnumerable<LanguageDto> GetAllValidLanguages()
|
||||
{
|
||||
return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c =>
|
||||
new LanguageDto()
|
||||
{
|
||||
Title = c.DisplayName,
|
||||
IsoCode = c.IetfLanguageTag
|
||||
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
|
||||
}
|
||||
}
|
||||
|
@ -63,6 +63,7 @@ namespace API.Controllers
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
}
|
||||
catch (Exception)
|
||||
|
@ -121,6 +121,12 @@ namespace API.Controllers
|
||||
return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId));
|
||||
}
|
||||
|
||||
[HttpGet("chapter-metadata")]
|
||||
public async Task<ActionResult<ChapterDto>> GetChapterMetadata(int chapterId)
|
||||
{
|
||||
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("update-rating")]
|
||||
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
@ -143,10 +149,27 @@ namespace API.Controllers
|
||||
{
|
||||
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
|
||||
}
|
||||
series.Name = updateSeries.Name.Trim();
|
||||
series.LocalizedName = updateSeries.LocalizedName.Trim();
|
||||
series.SortName = updateSeries.SortName?.Trim();
|
||||
series.Metadata.Summary = updateSeries.Summary?.Trim();
|
||||
|
||||
if (!series.Name.Equals(updateSeries.Name.Trim()))
|
||||
{
|
||||
series.Name = updateSeries.Name.Trim();
|
||||
series.NameLocked = true;
|
||||
}
|
||||
if (!series.SortName.Equals(updateSeries.SortName.Trim()))
|
||||
{
|
||||
series.SortName = updateSeries.SortName.Trim();
|
||||
series.SortNameLocked = true;
|
||||
}
|
||||
if (!series.LocalizedName.Equals(updateSeries.LocalizedName.Trim()))
|
||||
{
|
||||
series.LocalizedName = updateSeries.LocalizedName.Trim();
|
||||
series.LocalizedNameLocked = true;
|
||||
}
|
||||
|
||||
|
||||
if (!series.NameLocked) series.NameLocked = false;
|
||||
if (!series.SortNameLocked) series.SortNameLocked = false;
|
||||
if (!series.LocalizedNameLocked) series.LocalizedNameLocked = false;
|
||||
|
||||
var needsRefreshMetadata = false;
|
||||
// This is when you hit Reset
|
||||
|
@ -61,31 +61,5 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
/// <remarks>Metadata field</remarks>
|
||||
public string TitleName { get; set; }
|
||||
/// <summary>
|
||||
/// Summary for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Summary { get; set; }
|
||||
/// <summary>
|
||||
/// Language for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Language { get; set; }
|
||||
/// <summary>
|
||||
/// Number in the TotalCount of issues
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
/// <summary>
|
||||
/// Total number of issues for the series
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Penciller { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Inker { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Colorist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Letterer { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> CoverArtist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Editor { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Publisher { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
|
||||
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Metadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Exclusively metadata about a given chapter
|
||||
/// </summary>
|
||||
public class ChapterMetadataDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Penciller { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Inker { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Colorist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Letterer { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> CoverArtist { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Editor { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Publisher { get; set; } = new List<PersonDto>();
|
||||
public int ChapterId { get; set; }
|
||||
public ICollection<PersonDto> CoverArtists { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Publishers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Translators { get; set; } = new List<PersonDto>();
|
||||
|
||||
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Collection of all Tags from underlying chapters for a Series
|
||||
/// </summary>
|
||||
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
|
||||
public AgeRating AgeRating { get; set; }
|
||||
public string ReleaseDate { get; set; }
|
||||
public PublicationStatus PublicationStatus { get; set; }
|
||||
/// <summary>
|
||||
/// Summary for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Summary { get; set; }
|
||||
/// <summary>
|
||||
/// Language for the Chapter/Issue
|
||||
/// </summary>
|
||||
public string Language { get; set; }
|
||||
/// <summary>
|
||||
/// Number in the TotalCount of issues
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
/// <summary>
|
||||
/// Total number of issues for the series
|
||||
/// </summary>
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -33,6 +33,10 @@ namespace API.DTOs
|
||||
|
||||
public DateTime Created { get; set; }
|
||||
|
||||
public bool NameLocked { get; set; }
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
|
||||
public int LibraryId { get; set; }
|
||||
public string LibraryName { get; set; }
|
||||
}
|
||||
|
@ -56,6 +56,30 @@ namespace API.DTOs
|
||||
/// </summary>
|
||||
public PublicationStatus PublicationStatus { get; set; }
|
||||
|
||||
public bool LanguageLocked { get; set; }
|
||||
public bool SummaryLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override AgeRating
|
||||
/// </summary>
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
|
||||
/// </summary>
|
||||
public bool PublicationStatusLocked { get; set; }
|
||||
public bool GenresLocked { get; set; }
|
||||
public bool TagsLocked { get; set; }
|
||||
public bool WriterLocked { get; set; }
|
||||
public bool CharacterLocked { get; set; }
|
||||
public bool ColoristLocked { get; set; }
|
||||
public bool EditorLocked { get; set; }
|
||||
public bool InkerLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
|
||||
|
||||
public int SeriesId { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -6,10 +6,10 @@
|
||||
public string Name { get; init; }
|
||||
public string LocalizedName { get; init; }
|
||||
public string SortName { get; init; }
|
||||
public string Summary { get; init; }
|
||||
public byte[] CoverImage { get; init; }
|
||||
public int UserRating { get; set; }
|
||||
public string UserReview { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
|
||||
public bool UnlockName { get; set; }
|
||||
public bool UnlockSortName { get; set; }
|
||||
public bool UnlockLocalizedName { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,6 @@ namespace API.DTOs
|
||||
public class UpdateSeriesMetadataDto
|
||||
{
|
||||
public SeriesMetadataDto SeriesMetadata { get; set; }
|
||||
public ICollection<CollectionTagDto> Tags { get; set; }
|
||||
public ICollection<CollectionTagDto> CollectionTags { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ namespace API.Data.Metadata
|
||||
public int PageCount { get; set; }
|
||||
// ReSharper disable once InconsistentNaming
|
||||
/// <summary>
|
||||
/// ISO 639-1 Code to represent the language of the content
|
||||
/// IETF BCP 47 Code to represent the language of the content
|
||||
/// </summary>
|
||||
public string LanguageISO { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
|
1448
API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs
generated
Normal file
1448
API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
224
API/Data/Migrations/20220303205301_SeriesLockedFields.cs
Normal file
224
API/Data/Migrations/20220303205301_SeriesLockedFields.cs
Normal file
@ -0,0 +1,224 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class SeriesLockedFields : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AgeRatingLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CharacterLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ColoristLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CoverArtistLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EditorLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "GenresLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "InkerLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LanguageLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LettererLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PencillerLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PublicationStatusLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PublisherLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SummaryLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TagsLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TranslatorLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WriterLocked",
|
||||
table: "SeriesMetadata",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LocalizedNameLocked",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NameLocked",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SortNameLocked",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRatingLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ColoristLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CoverArtistLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EditorLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GenresLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InkerLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LanguageLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LettererLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PencillerLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PublicationStatusLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PublisherLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SummaryLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TagsLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TranslatorLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WriterLocked",
|
||||
table: "SeriesMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LocalizedNameLocked",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NameLocked",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SortNameLocked",
|
||||
table: "Series");
|
||||
}
|
||||
}
|
||||
}
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -502,15 +502,51 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AgeRatingLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CharacterLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ColoristLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Count")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("CoverArtistLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EditorLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("GenresLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("InkerLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LanguageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LettererLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PencillerLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PublicationStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PublicationStatusLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PublisherLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReleaseYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -524,6 +560,18 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SummaryLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("TagsLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("TranslatorLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WriterLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeriesId")
|
||||
@ -647,9 +695,15 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("LocalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LocalizedNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("NameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -662,6 +716,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("SortName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("SortNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("LibraryId");
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -13,6 +14,7 @@ public interface IAppUserProgressRepository
|
||||
Task<bool> UserHasProgress(LibraryType libraryType, int userId);
|
||||
Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId);
|
||||
Task<bool> HasAnyProgressOnSeriesAsync(int seriesId, int userId);
|
||||
Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId);
|
||||
}
|
||||
|
||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
@ -83,6 +85,19 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
.AnyAsync(aup => aup.PagesRead > 0 && aup.AppUserId == userId && aup.SeriesId == seriesId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This will return any user progress. This filters out progress rows that have no pages read.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<AppUserProgress>> GetUserProgressForSeriesAsync(int seriesId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
.Where(p => p.SeriesId == seriesId && p.AppUserId == userId && p.PagesRead > 0)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserProgress> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
@ -18,6 +19,7 @@ public interface IChapterRepository
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
Task<Chapter> GetChapterAsync(int chapterId);
|
||||
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
|
||||
Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId);
|
||||
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
|
||||
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
|
||||
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
|
||||
@ -46,6 +48,7 @@ public class ChapterRepository : IChapterRepository
|
||||
return await _context.Chapter
|
||||
.Where(c => chapterIds.Contains(c.Id))
|
||||
.Include(c => c.Volume)
|
||||
.AsSplitQuery()
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@ -113,6 +116,19 @@ public class ChapterRepository : IChapterRepository
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
return chapter;
|
||||
}
|
||||
|
||||
public async Task<ChapterMetadataDto> GetChapterMetadataDtoAsync(int chapterId)
|
||||
{
|
||||
var chapter = await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.ProjectTo<ChapterMetadataDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
|
||||
return chapter;
|
||||
@ -140,6 +156,7 @@ public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Include(c => c.Files)
|
||||
.AsSplitQuery()
|
||||
.SingleOrDefaultAsync(c => c.Id == chapterId);
|
||||
}
|
||||
|
||||
|
@ -1,34 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.Entities.Metadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Has a 1-to-1 relationship with a Chapter. Represents metadata about a chapter.
|
||||
/// </summary>
|
||||
public class ChapterMetadata
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Chapter title
|
||||
/// </summary>
|
||||
/// <remarks>This should not be confused with Chapter.Title which is used for special filenames.</remarks>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Year { get; set; } // Only time I can think this will be more than 1 year is for a volume which will be a spread
|
||||
public string StoryArc { get; set; } // This might be a list
|
||||
|
||||
/// <summary>
|
||||
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
||||
/// </summary>
|
||||
public ICollection<Person> People { get; set; } = new List<Person>();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Relationships
|
||||
public Chapter Chapter { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
|
||||
}
|
||||
}
|
@ -40,6 +40,31 @@ namespace API.Entities.Metadata
|
||||
public int Count { get; set; } = 0;
|
||||
public PublicationStatus PublicationStatus { get; set; }
|
||||
|
||||
// Locks
|
||||
public bool LanguageLocked { get; set; }
|
||||
public bool SummaryLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override AgeRating
|
||||
/// </summary>
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override PublicationStatus
|
||||
/// </summary>
|
||||
public bool PublicationStatusLocked { get; set; }
|
||||
public bool GenresLocked { get; set; }
|
||||
public bool TagsLocked { get; set; }
|
||||
public bool WriterLocked { get; set; }
|
||||
public bool CharacterLocked { get; set; }
|
||||
public bool ColoristLocked { get; set; }
|
||||
public bool EditorLocked { get; set; }
|
||||
public bool InkerLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
|
||||
|
||||
// Relationship
|
||||
public Series Series { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
@ -48,6 +73,7 @@ namespace API.Entities.Metadata
|
||||
[ConcurrencyCheck]
|
||||
public uint RowVersion { get; private set; }
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public void OnSavingChanges()
|
||||
{
|
||||
|
@ -58,6 +58,10 @@ namespace API.Entities
|
||||
/// </summary>
|
||||
public MangaFormat Format { get; set; } = MangaFormat.Unknown;
|
||||
|
||||
public bool NameLocked { get; set; }
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
|
||||
public SeriesMetadata Metadata { get; set; }
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
|
||||
public ICollection<AppUserProgress> Progress { get; set; } = new List<AppUserProgress>();
|
||||
|
@ -21,45 +21,16 @@ namespace API.Helpers
|
||||
public AutoMapperProfiles()
|
||||
{
|
||||
CreateMap<LibraryDto, Library>();
|
||||
|
||||
CreateMap<Volume, VolumeDto>();
|
||||
|
||||
CreateMap<MangaFile, MangaFileDto>();
|
||||
|
||||
CreateMap<Chapter, ChapterDto>()
|
||||
.ForMember(dest => dest.Writers,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer)))
|
||||
.ForMember(dest => dest.CoverArtist,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist)))
|
||||
.ForMember(dest => dest.Colorist,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist)))
|
||||
.ForMember(dest => dest.Inker,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker)))
|
||||
.ForMember(dest => dest.Letterer,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer)))
|
||||
.ForMember(dest => dest.Penciller,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller)))
|
||||
.ForMember(dest => dest.Publisher,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher)))
|
||||
.ForMember(dest => dest.Editor,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)))
|
||||
.ForMember(dest => dest.Translators,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator)));
|
||||
|
||||
CreateMap<Chapter, ChapterDto>();
|
||||
CreateMap<Series, SeriesDto>();
|
||||
CreateMap<CollectionTag, CollectionTagDto>();
|
||||
CreateMap<Person, PersonDto>();
|
||||
CreateMap<Genre, GenreTagDto>();
|
||||
CreateMap<Tag, TagDto>();
|
||||
CreateMap<AgeRating, AgeRatingDto>();
|
||||
CreateMap<PublicationStatus, PublicationStatusDto>();
|
||||
|
||||
CreateMap<SeriesMetadata, SeriesMetadataDto>()
|
||||
.ForMember(dest => dest.Writers,
|
||||
@ -93,29 +64,35 @@ namespace API.Helpers
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
||||
|
||||
CreateMap<ChapterMetadata, ChapterMetadataDto>()
|
||||
CreateMap<Chapter, ChapterMetadataDto>()
|
||||
.ForMember(dest => dest.Writers,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer)))
|
||||
.ForMember(dest => dest.CoverArtist,
|
||||
.ForMember(dest => dest.CoverArtists,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist)))
|
||||
.ForMember(dest => dest.Colorist,
|
||||
.ForMember(dest => dest.Colorists,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist)))
|
||||
.ForMember(dest => dest.Inker,
|
||||
.ForMember(dest => dest.Inkers,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker)))
|
||||
.ForMember(dest => dest.Letterer,
|
||||
.ForMember(dest => dest.Letterers,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer)))
|
||||
.ForMember(dest => dest.Penciller,
|
||||
.ForMember(dest => dest.Pencillers,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller)))
|
||||
.ForMember(dest => dest.Publisher,
|
||||
.ForMember(dest => dest.Publishers,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher)))
|
||||
.ForMember(dest => dest.Editor,
|
||||
.ForMember(dest => dest.Translators,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator)))
|
||||
.ForMember(dest => dest.Characters,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character)))
|
||||
.ForMember(dest => dest.Editors,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
||||
|
||||
|
@ -337,29 +337,67 @@ public class ReaderService : IReaderService
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the chapter to continue reading from. If a chapter has progress and not complete, return that. If not, progress in the
|
||||
/// ordering (Volumes -> Loose Chapters -> Special) to find next chapter. If all are read, return first in order for series.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<ChapterDto> GetContinuePoint(int seriesId, int userId)
|
||||
{
|
||||
// Loop through all chapters that are not in volume 0
|
||||
var progress = (await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId)).ToList();
|
||||
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
|
||||
|
||||
var nonSpecialChapters = volumes
|
||||
if (progress.Count == 0)
|
||||
{
|
||||
// I think i need a way to sort volumes last
|
||||
return volumes.OrderBy(v => double.Parse(v.Number + ""), _chapterSortComparer).First().Chapters
|
||||
.OrderBy(c => float.Parse(c.Number)).First();
|
||||
}
|
||||
|
||||
// Loop through all chapters that are not in volume 0
|
||||
var volumeChapters = volumes
|
||||
.Where(v => v.Number != 0)
|
||||
.SelectMany(v => v.Chapters)
|
||||
.OrderBy(c => float.Parse(c.Number))
|
||||
.ToList();
|
||||
|
||||
var currentlyReadingChapter = nonSpecialChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages);
|
||||
|
||||
|
||||
// If there are any volumes that have progress, return those. If not, move on.
|
||||
var currentlyReadingChapter = volumeChapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0);
|
||||
if (currentlyReadingChapter != null) return currentlyReadingChapter;
|
||||
|
||||
// Check if there are any specials
|
||||
// Check loose leaf chapters (and specials). First check if there are any
|
||||
var volume = volumes.SingleOrDefault(v => v.Number == 0);
|
||||
if (volume == null) return nonSpecialChapters.First();
|
||||
return FindNextReadingChapter(volume == null ? volumeChapters : volume.Chapters.OrderBy(c => float.Parse(c.Number)).ToList());
|
||||
}
|
||||
|
||||
var chapters = volume.Chapters.OrderBy(c => float.Parse(c.Number)).ToList();
|
||||
private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters)
|
||||
{
|
||||
var chaptersWithProgress = volumeChapters.Where(c => c.PagesRead > 0).ToList();
|
||||
if (chaptersWithProgress.Count > 0)
|
||||
{
|
||||
var last = chaptersWithProgress.FindLastIndex(c => c.PagesRead > 0);
|
||||
if (last + 1 < chaptersWithProgress.Count)
|
||||
{
|
||||
return chaptersWithProgress.ElementAt(last + 1);
|
||||
}
|
||||
|
||||
return chapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages) ?? chapters.First();
|
||||
var lastChapter = chaptersWithProgress.ElementAt(last);
|
||||
if (lastChapter.PagesRead < lastChapter.Pages)
|
||||
{
|
||||
return chaptersWithProgress.ElementAt(last);
|
||||
}
|
||||
|
||||
// chaptersWithProgress are all read, then we need to get the next chapter that doesn't have progress
|
||||
var lastIndexWithProgress = volumeChapters.IndexOf(lastChapter);
|
||||
if (lastIndexWithProgress + 1 < volumeChapters.Count)
|
||||
{
|
||||
return volumeChapters.ElementAt(lastIndexWithProgress + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return volumeChapters.First();
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@ -6,10 +7,14 @@ using System.Threading.Tasks;
|
||||
using API.Comparators;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.VisualBasic;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
@ -44,52 +49,100 @@ public class SeriesService : ISeriesService
|
||||
{
|
||||
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
|
||||
var allCollectionTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
|
||||
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToList();
|
||||
var allPeople = (await _unitOfWork.PersonRepository.GetAllPeople()).ToList();
|
||||
var allTags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToList();
|
||||
|
||||
if (series.Metadata == null)
|
||||
{
|
||||
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
|
||||
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.CollectionTags
|
||||
.Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
|
||||
}
|
||||
else
|
||||
{
|
||||
if (series.Metadata.AgeRating != updateSeriesMetadataDto.SeriesMetadata.AgeRating)
|
||||
{
|
||||
series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata.AgeRating;
|
||||
series.Metadata.AgeRatingLocked = true;
|
||||
}
|
||||
|
||||
if (series.Metadata.PublicationStatus != updateSeriesMetadataDto.SeriesMetadata.PublicationStatus)
|
||||
{
|
||||
series.Metadata.PublicationStatus = updateSeriesMetadataDto.SeriesMetadata.PublicationStatus;
|
||||
series.Metadata.PublicationStatusLocked = true;
|
||||
}
|
||||
|
||||
if (series.Metadata.Summary != updateSeriesMetadataDto.SeriesMetadata.Summary.Trim())
|
||||
{
|
||||
series.Metadata.Summary = updateSeriesMetadataDto.SeriesMetadata?.Summary.Trim();
|
||||
series.Metadata.SummaryLocked = true;
|
||||
}
|
||||
|
||||
|
||||
series.Metadata.CollectionTags ??= new List<CollectionTag>();
|
||||
// TODO: Move this merging logic into a reusable code as it can be used for any Tag
|
||||
var newTags = new List<CollectionTag>();
|
||||
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.CollectionTags.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.CollectionTags.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
|
||||
{
|
||||
newTags.Add(existingTag);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in newTags)
|
||||
UpdateRelatedList(updateSeriesMetadataDto.CollectionTags, series, allCollectionTags, (tag) =>
|
||||
{
|
||||
series.Metadata.CollectionTags.Add(tag);
|
||||
});
|
||||
|
||||
series.Metadata.Genres ??= new List<Genre>();
|
||||
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata.Genres, series, allGenres, (genre) =>
|
||||
{
|
||||
series.Metadata.Genres.Add(genre);
|
||||
}, () => series.Metadata.GenresLocked = true);
|
||||
|
||||
series.Metadata.Tags ??= new List<Tag>();
|
||||
UpdateTagList(updateSeriesMetadataDto.SeriesMetadata.Tags, series, allTags, (tag) =>
|
||||
{
|
||||
series.Metadata.Tags.Add(tag);
|
||||
}, () => series.Metadata.TagsLocked = true);
|
||||
|
||||
void HandleAddPerson(Person person)
|
||||
{
|
||||
PersonHelper.AddPersonIfNotExists(series.Metadata.People, person);
|
||||
allPeople.Add(person);
|
||||
}
|
||||
|
||||
series.Metadata.People ??= new List<Person>();
|
||||
UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata.Writers, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.WriterLocked = true);
|
||||
UpdatePeopleList(PersonRole.Character, updateSeriesMetadataDto.SeriesMetadata.Characters, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.CharacterLocked = true);
|
||||
UpdatePeopleList(PersonRole.Colorist, updateSeriesMetadataDto.SeriesMetadata.Colorists, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.ColoristLocked = true);
|
||||
UpdatePeopleList(PersonRole.Editor, updateSeriesMetadataDto.SeriesMetadata.Editors, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.EditorLocked = true);
|
||||
UpdatePeopleList(PersonRole.Inker, updateSeriesMetadataDto.SeriesMetadata.Inkers, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.InkerLocked = true);
|
||||
UpdatePeopleList(PersonRole.Letterer, updateSeriesMetadataDto.SeriesMetadata.Letterers, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.LettererLocked = true);
|
||||
UpdatePeopleList(PersonRole.Penciller, updateSeriesMetadataDto.SeriesMetadata.Pencillers, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.PencillerLocked = true);
|
||||
UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.PublisherLocked = true);
|
||||
UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.TranslatorLocked = true);
|
||||
UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allPeople,
|
||||
HandleAddPerson, () => series.Metadata.CoverArtistLocked = true);
|
||||
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked) series.Metadata.AgeRatingLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked) series.Metadata.PublicationStatusLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.LanguageLocked) series.Metadata.LanguageLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.GenresLocked) series.Metadata.GenresLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.TagsLocked) series.Metadata.TagsLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.CharacterLocked) series.Metadata.CharacterLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.ColoristLocked) series.Metadata.ColoristLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.EditorLocked) series.Metadata.EditorLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.InkerLocked) series.Metadata.InkerLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.LettererLocked) series.Metadata.LettererLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.PencillerLocked) series.Metadata.PencillerLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.PublisherLocked) series.Metadata.PublisherLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.TranslatorLocked) series.Metadata.TranslatorLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.CoverArtistLocked) series.Metadata.CoverArtistLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.WriterLocked) series.Metadata.WriterLocked = false;
|
||||
if (!updateSeriesMetadataDto.SeriesMetadata.SummaryLocked) series.Metadata.SummaryLocked = false;
|
||||
|
||||
}
|
||||
|
||||
if (!_unitOfWork.HasChanges())
|
||||
@ -99,13 +152,16 @@ public class SeriesService : ISeriesService
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
foreach (var tag in updateSeriesMetadataDto.Tags)
|
||||
foreach (var tag in updateSeriesMetadataDto.CollectionTags)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection,
|
||||
MessageFactory.SeriesAddedToCollectionEvent(tag.Id,
|
||||
updateSeriesMetadataDto.SeriesMetadata.SeriesId), false);
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.ScanSeries,
|
||||
MessageFactory.ScanSeriesEvent(series.Id, series.Name), false);
|
||||
|
||||
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
|
||||
|
||||
return true;
|
||||
@ -120,6 +176,165 @@ public class SeriesService : ISeriesService
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Move this to a helper so we can easily test
|
||||
private static void UpdateRelatedList(ICollection<CollectionTagDto> tags, Series series, IReadOnlyCollection<CollectionTag> allTags,
|
||||
Action<CollectionTag> handleAdd)
|
||||
{
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.CollectionTags.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.CollectionTags.Remove(existing);
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
|
||||
{
|
||||
handleAdd(existingTag);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateGenreList(ICollection<GenreTagDto> tags, Series series, IReadOnlyCollection<Genre> allTags, Action<Genre> handleAdd, Action onModified)
|
||||
{
|
||||
var isModified = false;
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.Genres.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.Genres.Remove(existing);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tagTitle in tags.Select(t => t.Title))
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tagTitle);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.Genres.All(t => t.Title != tagTitle))
|
||||
{
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(DbFactory.Genre(tagTitle, false));
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModified)
|
||||
{
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateTagList(ICollection<TagDto> tags, Series series, IReadOnlyCollection<Tag> allTags, Action<Tag> handleAdd, Action onModified)
|
||||
{
|
||||
var isModified = false;
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.Tags.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null)
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.Tags.Remove(existing);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tagTitle in tags.Select(t => t.Title))
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Title == tagTitle);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.Tags.All(t => t.Title != tagTitle))
|
||||
{
|
||||
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(DbFactory.Tag(tagTitle, false));
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModified)
|
||||
{
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdatePeopleList(PersonRole role, ICollection<PersonDto> tags, Series series, IReadOnlyCollection<Person> allTags,
|
||||
Action<Person> handleAdd, Action onModified)
|
||||
{
|
||||
var isModified = false;
|
||||
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
|
||||
var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
|
||||
{
|
||||
// Remove tag
|
||||
series.Metadata.People.Remove(existing);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var existingTag = allTags.SingleOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (series.Metadata.People.All(t => t.Name != tag.Name && t.Role == tag.Role))
|
||||
{
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(DbFactory.Person(tag.Name, role));
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModified)
|
||||
{
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
|
@ -115,6 +115,12 @@ namespace API.Services.Tasks.Scanner
|
||||
{
|
||||
info.Chapters = info.ComicInfo.Number;
|
||||
}
|
||||
|
||||
// Patch is SeriesSort from ComicInfo
|
||||
if (info.ComicInfo != null && !string.IsNullOrEmpty(info.ComicInfo.TitleSort))
|
||||
{
|
||||
info.SeriesSort = info.ComicInfo.TitleSort;
|
||||
}
|
||||
}
|
||||
|
||||
TrackSeries(info);
|
||||
|
@ -328,7 +328,7 @@ public class ScannerService : IScannerService
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
||||
"[ScannerService] Finished scan of {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}",
|
||||
totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name);
|
||||
}
|
||||
else
|
||||
@ -351,8 +351,7 @@ public class ScannerService : IScannerService
|
||||
var parsedSeries = await scanner.ScanLibrariesForSeries(library.Type, dirs, library.Name);
|
||||
var totalFiles = parsedSeries.Keys.Sum(key => parsedSeries[key].Count);
|
||||
var scanElapsedTime = scanWatch.ElapsedMilliseconds;
|
||||
_logger.LogInformation("Scanned {TotalFiles} files in {ElapsedScanTime} milliseconds", totalFiles,
|
||||
scanElapsedTime);
|
||||
|
||||
return new Tuple<int, long, Dictionary<ParsedSeries, List<ParserInfo>>>(totalFiles, scanElapsedTime, parsedSeries);
|
||||
}
|
||||
|
||||
@ -426,24 +425,15 @@ public class ScannerService : IScannerService
|
||||
// Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series
|
||||
var librarySeries = cleanedSeries.ToList();
|
||||
|
||||
//var index = 0;
|
||||
foreach (var series in librarySeries)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name));
|
||||
await UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library);
|
||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
||||
// MessageFactory.ScanLibraryProgressEvent(library.Id, (1F * index) / librarySeries.Count));
|
||||
// index += 1;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Update the people, genres, and tags after committing as we might have inserted new ones.
|
||||
allPeople = await _unitOfWork.PersonRepository.GetAllPeople();
|
||||
allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync();
|
||||
allTags = await _unitOfWork.TagRepository.GetAllTagsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -471,10 +461,6 @@ public class ScannerService : IScannerService
|
||||
// This is something more like, the series has finished updating in the backend. It may or may not have been modified.
|
||||
await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.Id, series.Name));
|
||||
}
|
||||
|
||||
//var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize));
|
||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
||||
// MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
|
||||
}
|
||||
|
||||
|
||||
@ -484,6 +470,7 @@ public class ScannerService : IScannerService
|
||||
var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList();
|
||||
_logger.LogDebug("[ScannerService] Fetched {AllSeriesCount} series for comparing new series with. There should be {DeltaToParsedSeries} new series",
|
||||
allSeries.Count, parsedSeries.Count - allSeries.Count);
|
||||
// TODO: Once a parsedSeries is processed, remove the key to free up some memory
|
||||
foreach (var (key, infos) in parsedSeries)
|
||||
{
|
||||
// Key is normalized already
|
||||
@ -518,7 +505,6 @@ public class ScannerService : IScannerService
|
||||
}
|
||||
|
||||
|
||||
var i = 0;
|
||||
foreach(var series in newSeries)
|
||||
{
|
||||
_logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||
@ -539,11 +525,6 @@ public class ScannerService : IScannerService
|
||||
_logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ",
|
||||
series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}");
|
||||
}
|
||||
|
||||
//var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count));
|
||||
// await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress,
|
||||
// MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
|
||||
i++;
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended));
|
||||
@ -559,8 +540,6 @@ public class ScannerService : IScannerService
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName);
|
||||
//await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Started));
|
||||
//await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Updated));
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name));
|
||||
|
||||
// Get all associated ParsedInfos to the series. This includes infos that use a different filename that matches Series LocalizedName
|
||||
@ -575,8 +554,8 @@ public class ScannerService : IScannerService
|
||||
series.Format = parsedInfos[0].Format;
|
||||
}
|
||||
series.OriginalName ??= parsedInfos[0].Series;
|
||||
series.SortName ??= parsedInfos[0].SeriesSort;
|
||||
//await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Updated));
|
||||
if (!series.SortNameLocked) series.SortName ??= parsedInfos[0].SeriesSort;
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name));
|
||||
|
||||
UpdateSeriesMetadata(series, allPeople, allGenres, allTags, library.Type);
|
||||
@ -585,7 +564,7 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
_logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name);
|
||||
}
|
||||
//await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Ended));
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name));
|
||||
}
|
||||
|
||||
@ -624,65 +603,116 @@ public class ScannerService : IScannerService
|
||||
}
|
||||
|
||||
// Set the AgeRating as highest in all the comicInfos
|
||||
series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating);
|
||||
if (!series.Metadata.AgeRatingLocked) series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating);
|
||||
|
||||
|
||||
series.Metadata.Count = chapters.Max(chapter => chapter.TotalCount);
|
||||
series.Metadata.PublicationStatus = PublicationStatus.OnGoing;
|
||||
if (chapters.Max(chapter => chapter.Count) >= series.Metadata.Count && series.Metadata.Count > 0)
|
||||
if (!series.Metadata.PublicationStatusLocked)
|
||||
{
|
||||
series.Metadata.PublicationStatus = PublicationStatus.Completed;
|
||||
series.Metadata.PublicationStatus = PublicationStatus.OnGoing;
|
||||
if (chapters.Max(chapter => chapter.Count) >= series.Metadata.Count && series.Metadata.Count > 0)
|
||||
{
|
||||
series.Metadata.PublicationStatus = PublicationStatus.Completed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(firstChapter.Summary))
|
||||
if (!string.IsNullOrEmpty(firstChapter.Summary) && !series.Metadata.SummaryLocked)
|
||||
{
|
||||
series.Metadata.Summary = firstChapter.Summary;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(firstChapter.Language))
|
||||
if (!string.IsNullOrEmpty(firstChapter.Language) && !series.Metadata.LanguageLocked)
|
||||
{
|
||||
series.Metadata.Language = firstChapter.Language;
|
||||
}
|
||||
|
||||
|
||||
void HandleAddPerson(Person person)
|
||||
{
|
||||
PersonHelper.AddPersonIfNotExists(series.Metadata.People, person);
|
||||
allPeople.Add(person);
|
||||
}
|
||||
|
||||
// Handle People
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.WriterLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => p.Name), PersonRole.CoverArtist,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.CoverArtistLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => p.Name), PersonRole.CoverArtist,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Publisher).Select(p => p.Name), PersonRole.Publisher,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.PublisherLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Publisher).Select(p => p.Name), PersonRole.Publisher,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Name), PersonRole.Character,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.CharacterLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Name), PersonRole.Character,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Colorist).Select(p => p.Name), PersonRole.Colorist,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.ColoristLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Colorist).Select(p => p.Name), PersonRole.Colorist,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Editor).Select(p => p.Name), PersonRole.Editor,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.EditorLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Editor).Select(p => p.Name), PersonRole.Editor,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Inker).Select(p => p.Name), PersonRole.Inker,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.InkerLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Inker).Select(p => p.Name), PersonRole.Inker,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Letterer).Select(p => p.Name), PersonRole.Letterer,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.LettererLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Letterer).Select(p => p.Name), PersonRole.Letterer,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.PencillerLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator,
|
||||
person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person));
|
||||
if (!series.Metadata.TranslatorLocked)
|
||||
{
|
||||
PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator,
|
||||
HandleAddPerson);
|
||||
}
|
||||
|
||||
TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) =>
|
||||
TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag));
|
||||
if (!series.Metadata.TagsLocked)
|
||||
{
|
||||
TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) =>
|
||||
{
|
||||
TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag);
|
||||
allTags.Add(tag);
|
||||
});
|
||||
}
|
||||
|
||||
GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre =>
|
||||
GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre));
|
||||
if (!series.Metadata.GenresLocked)
|
||||
{
|
||||
GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre =>
|
||||
{
|
||||
GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre);
|
||||
allGenres.Add(genre);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var people = chapters.SelectMany(c => c.People).ToList();
|
||||
@ -708,7 +738,6 @@ public class ScannerService : IScannerService
|
||||
_unitOfWork.VolumeRepository.Add(volume);
|
||||
}
|
||||
|
||||
// TODO: Here we can put a signalR update
|
||||
_logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name);
|
||||
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
|
||||
UpdateChapters(volume, infos);
|
||||
|
@ -14,7 +14,7 @@ namespace API.SignalR
|
||||
/// </summary>
|
||||
public const string UpdateAvailable = "UpdateAvailable";
|
||||
/// <summary>
|
||||
/// Used to tell when a scan series completes
|
||||
/// Used to tell when a scan series completes. This also informs UI to update series metadata
|
||||
/// </summary>
|
||||
public const string ScanSeries = "ScanSeries";
|
||||
/// <summary>
|
||||
|
6
UI/Web/package-lock.json
generated
6
UI/Web/package-lock.json
generated
@ -2405,9 +2405,9 @@
|
||||
}
|
||||
},
|
||||
"@ng-bootstrap/ng-bootstrap": {
|
||||
"version": "12.0.0-beta.4",
|
||||
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0-beta.4.tgz",
|
||||
"integrity": "sha512-iOXZT4FLouAGJDRw4ruogyR+lg648nywNWKUxW7l+mtMC9i4kdpfo4beQ/nqb4Uq2zMDs9zj4MbKVI391+kMnA==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-12.0.0.tgz",
|
||||
"integrity": "sha512-XWf/CsP1gH0aev7Mtsldtj0DPPFdTrJpSiyjzLFS29gU1ZuDlJz6OKthgUDxZoua6uNPAzaGMc0A20T+reMfRw==",
|
||||
"requires": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
|
@ -28,7 +28,7 @@
|
||||
"@angular/router": "~13.2.2",
|
||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
||||
"@microsoft/signalr": "^6.0.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^12.0.0-beta.4",
|
||||
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
|
||||
"@ngx-lite/nav-drawer": "^0.4.7",
|
||||
"@ngx-lite/util": "0.0.1",
|
||||
"@popperjs/core": "^2.11.2",
|
||||
|
@ -1,16 +1,37 @@
|
||||
import { Genre } from "./genre";
|
||||
import { AgeRating } from "./metadata/age-rating";
|
||||
import { PublicationStatus } from "./metadata/publication-status";
|
||||
import { Person } from "./person";
|
||||
import { Tag } from "./tag";
|
||||
|
||||
export interface ChapterMetadata {
|
||||
id: number;
|
||||
chapterId: number;
|
||||
title: string;
|
||||
year: string;
|
||||
|
||||
ageRating: AgeRating;
|
||||
releaseDate: string;
|
||||
language: string;
|
||||
publicationStatus: PublicationStatus;
|
||||
summary: string;
|
||||
count: number;
|
||||
totalCount: number;
|
||||
|
||||
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
penciller: Array<Person>;
|
||||
inker: Array<Person>;
|
||||
colorist: Array<Person>;
|
||||
letterer: Array<Person>;
|
||||
coverArtist: Array<Person>;
|
||||
editor: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
|
||||
|
||||
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import { MangaFile } from './manga-file';
|
||||
import { Person } from './person';
|
||||
import { Tag } from './tag';
|
||||
|
||||
/**
|
||||
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
|
||||
*/
|
||||
export interface Chapter {
|
||||
id: number;
|
||||
range: string;
|
||||
@ -18,19 +19,8 @@ export interface Chapter {
|
||||
isSpecial: boolean;
|
||||
title: string;
|
||||
created: string;
|
||||
|
||||
titleName: string;
|
||||
/**
|
||||
* This is only Year and Month, Day is not supported from underlying sources
|
||||
* Actual name of the Chapter if populated in underlying metadata
|
||||
*/
|
||||
releaseDate: string;
|
||||
writers: Array<Person>;
|
||||
penciller: Array<Person>;
|
||||
inker: Array<Person>;
|
||||
colorist: Array<Person>;
|
||||
letterer: Array<Person>;
|
||||
coverArtist: Array<Person>;
|
||||
editor: Array<Person>;
|
||||
publisher: Array<Person>;
|
||||
tags: Array<Tag>;
|
||||
titleName: string;
|
||||
}
|
||||
|
@ -6,11 +6,12 @@ import { Person } from "./person";
|
||||
import { Tag } from "./tag";
|
||||
|
||||
export interface SeriesMetadata {
|
||||
publisher: string;
|
||||
seriesId: number;
|
||||
summary: string;
|
||||
collectionTags: Array<CollectionTag>;
|
||||
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
collectionTags: Array<CollectionTag>;
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
@ -24,6 +25,23 @@ export interface SeriesMetadata {
|
||||
ageRating: AgeRating;
|
||||
releaseYear: number;
|
||||
language: string;
|
||||
seriesId: number;
|
||||
publicationStatus: PublicationStatus;
|
||||
|
||||
summaryLocked: boolean;
|
||||
genresLocked: boolean;
|
||||
tagsLocked: boolean;
|
||||
writersLocked: boolean;
|
||||
coverArtistsLocked: boolean;
|
||||
publishersLocked: boolean;
|
||||
charactersLocked: boolean;
|
||||
pencillersLocked: boolean;
|
||||
inkersLocked: boolean;
|
||||
coloristsLocked: boolean;
|
||||
letterersLocked: boolean;
|
||||
editorsLocked: boolean;
|
||||
translatorsLocked: boolean;
|
||||
ageRatingLocked: boolean;
|
||||
releaseYearLocked: boolean;
|
||||
languageLocked: boolean;
|
||||
publicationStatusLocked: boolean;
|
||||
}
|
@ -11,6 +11,9 @@ export interface Series {
|
||||
localizedName: string;
|
||||
sortName: string;
|
||||
coverImageLocked: boolean;
|
||||
sortNameLocked: boolean;
|
||||
localizedNameLocked: boolean;
|
||||
nameLocked: boolean;
|
||||
volumes: Volume[];
|
||||
/**
|
||||
* Total pages in series
|
||||
|
@ -8,5 +8,5 @@ export interface Volume {
|
||||
lastModified: string;
|
||||
pages: number;
|
||||
pagesRead: number;
|
||||
chapters?: Array<Chapter>;
|
||||
chapters: Array<Chapter>; // TODO: Validate any cases where this is undefined
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ export class ActionFactoryService {
|
||||
|
||||
this.chapterActions.push({
|
||||
action: Action.Edit,
|
||||
title: 'Info',
|
||||
title: 'Details',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
});
|
||||
@ -247,7 +247,7 @@ export class ActionFactoryService {
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Info',
|
||||
title: 'Details',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
}
|
||||
|
@ -1,15 +1,17 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from '../typeahead/typeahead-settings';
|
||||
import { ChapterMetadata } from '../_models/chapter-metadata';
|
||||
import { Genre } from '../_models/genre';
|
||||
import { AgeRating } from '../_models/metadata/age-rating';
|
||||
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
|
||||
import { Language } from '../_models/metadata/language';
|
||||
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
|
||||
import { Person } from '../_models/person';
|
||||
import { Person, PersonRole } from '../_models/person';
|
||||
import { Tag } from '../_models/tag';
|
||||
|
||||
@Injectable({
|
||||
@ -21,7 +23,7 @@ export class MetadataService {
|
||||
|
||||
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getAgeRating(ageRating: AgeRating) {
|
||||
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
|
||||
@ -77,6 +79,13 @@ export class MetadataService {
|
||||
return this.httpClient.get<Array<Language>>(this.baseUrl + method);
|
||||
}
|
||||
|
||||
/**
|
||||
* All the potential language tags there can be
|
||||
*/
|
||||
getAllValidLanguages() {
|
||||
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages');
|
||||
}
|
||||
|
||||
getAllPeople(libraries?: Array<number>) {
|
||||
let method = 'metadata/people'
|
||||
if (libraries != undefined && libraries.length > 0) {
|
||||
|
@ -4,6 +4,7 @@ import { of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { ChapterMetadata } from '../_models/chapter-metadata';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { PaginatedResult } from '../_models/pagination';
|
||||
import { RecentlyAddedItem } from '../_models/recently-added-item';
|
||||
@ -85,6 +86,10 @@ export class SeriesService {
|
||||
return this.httpClient.get<Chapter>(this.baseUrl + 'series/chapter?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
getChapterMetadata(chapterId: number) {
|
||||
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
getData(id: number) {
|
||||
return of(id);
|
||||
}
|
||||
@ -161,10 +166,10 @@ export class SeriesService {
|
||||
}));
|
||||
}
|
||||
|
||||
updateMetadata(seriesMetadata: SeriesMetadata, tags: CollectionTag[]) {
|
||||
updateMetadata(seriesMetadata: SeriesMetadata, collectionTags: CollectionTag[]) {
|
||||
const data = {
|
||||
seriesMetadata,
|
||||
tags
|
||||
collectionTags,
|
||||
};
|
||||
return this.httpClient.post(this.baseUrl + 'series/metadata', data, {responseType: 'text' as 'json'});
|
||||
}
|
||||
@ -173,11 +178,6 @@ export class SeriesService {
|
||||
let params = new HttpParams();
|
||||
|
||||
params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||
|
||||
// NOTE: I'm not sure the paginated result is doing anything
|
||||
// if (this.paginatedSeriesForTagsResults?.pagination !== undefined && this.paginatedSeriesForTagsResults?.pagination?.currentPage === pageNum) {
|
||||
// return of(this.paginatedSeriesForTagsResults);
|
||||
// }
|
||||
|
||||
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'series/series-by-collection?collectionId=' + collectionTagId, {observe: 'response', params}).pipe(
|
||||
map((response: any) => {
|
||||
|
@ -58,7 +58,7 @@
|
||||
|
||||
<h4>Email Services (SMTP)</h4>
|
||||
<p class="accent">Kavita comes out of the box with an email service to power flows like invite user, forgot password, etc. Emails sent via our service are deleted immediately. You can use your own
|
||||
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails althought confirmation links will always
|
||||
email service. Set the url of the email service and use the Test button to ensure it works. At any time you can reset to ours. There is no way to disable emails although confirmation links will always
|
||||
be saved to logs.
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
|
@ -29,13 +29,12 @@ import { CollectionsModule } from './collections/collections.module';
|
||||
import { ReadingListModule } from './reading-list/reading-list.module';
|
||||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
|
||||
import { PersonRolePipe } from './_pipes/person-role.pipe';
|
||||
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
|
||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||
import { RegistrationModule } from './registration/registration.module';
|
||||
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
|
||||
import { PublicationStatusPipe } from './_pipes/publication-status.pipe';
|
||||
import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
import { PipeModule } from './pipe/pipe.module';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -51,8 +50,6 @@ import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
OnDeckComponent,
|
||||
DashboardComponent,
|
||||
NavEventsToggleComponent,
|
||||
PersonRolePipe,
|
||||
PublicationStatusPipe,
|
||||
SeriesMetadataDetailComponent,
|
||||
AllSeriesComponent,
|
||||
GroupedTypeaheadComponent,
|
||||
@ -83,6 +80,7 @@ import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
RegistrationModule,
|
||||
|
||||
NgbAccordionModule, // ThemeTest Component only
|
||||
PipeModule,
|
||||
|
||||
|
||||
ToastrModule.forRoot({
|
||||
|
@ -9,76 +9,145 @@
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal" *ngIf="utilityService.isChapter(data)">
|
||||
<ng-container *ngIf="utilityService.isChapter(data)">
|
||||
<app-chapter-metadata-detail [chapter]="data"></app-chapter-metadata-detail>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal" *ngIf="utilityService.isVolume(data)">
|
||||
<h4 *ngIf="utilityService.isVolume(data)">Information</h4>
|
||||
|
||||
<ng-container *ngIf="utilityService.isVolume(data) || utilityService.isChapter(data)">
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Id: {{data.id}}
|
||||
</div>
|
||||
<div class="col" *ngIf="series !== undefined">
|
||||
Format: <span class="badge bg-secondary">{{utilityService.mangaFormat(series.format) | sentenceCase}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||
Added: {{(data.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{data.pages}}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
<span >
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</span>
|
||||
<span class="badge bg-primary rounded-pill">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>File(s)</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<li [ngbNavItem]="tabs[0]" *ngIf="!tabs[0].disabled">
|
||||
<a ngbNavLink>{{tabs[0].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="container-fluid row g-0">
|
||||
<div class="col-md-2 col-xs-4 col-sm-6">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
ID: {{data.id}}
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6">
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
<h4>
|
||||
{{chapter?.titleName}}
|
||||
</h4>
|
||||
<span>
|
||||
<span *ngIf="chapterMetadata && chapterMetadata.releaseDate !== null">Release Date: {{chapterMetadata.releaseDate | date: 'shortDate' || '-'}}</span>
|
||||
</span>
|
||||
<span class="text-accent">{{chapter.pages}} pages</span>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
Added: {{(chapter.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||
Added: {{(data.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
Age Rating: {{ageRating}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngIf="chapterMetadata !== undefined">
|
||||
<div class="row g-0" *ngIf="chapterMetadata.tags && chapterMetadata.tags.length > 0">
|
||||
<h6>Tags</h6>
|
||||
<app-badge-expander [items]="chapterMetadata.tags">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge>{{item.title}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
<div class="row g-0" *ngIf="chapterMetadata.genres && chapterMetadata.genres.length > 0">
|
||||
<h6>Genres</h6>
|
||||
<app-badge-expander [items]="chapterMetadata.genres">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-tag-badge>{{item.title}}</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[1]" *ngIf="!tabs[1].disabled">
|
||||
<a ngbNavLink>{{tabs[1].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-chapter-metadata-detail [chapter]="chapterMetadata"></app-chapter-metadata-detail>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[2]" *ngIf="!tabs[2].disabled">
|
||||
<a ngbNavLink>{{tabs[2].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
<div class="row g-0">
|
||||
<button class="btn btn-primary flex-end mb-2" [disabled]="coverImageSaveLoading" (click)="saveCoverImage()">
|
||||
<ng-container *ngIf="coverImageSaveLoading; else notSaving">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</ng-container>
|
||||
<ng-template #notSaving>
|
||||
Save
|
||||
</ng-template>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[3]" *ngIf="!tabs[3].disabled">
|
||||
<a ngbNavLink>{{tabs[3].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let bookmark of bookmarks; let idx = index">
|
||||
<app-bookmark [bookmark]="bookmark" class="col-auto" (bookmarkRemoved)="removeBookmark(bookmark, idx)"></app-bookmark>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[4]" *ngIf="!tabs[4].disabled">
|
||||
<a ngbNavLink>{{tabs[4].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex my-4" *ngFor="let chapter of chapters">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
|
||||
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</span>
|
||||
<span class="badge bg-primary rounded-pill">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>File(s)</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
</div>
|
||||
<div class="col" *ngIf="data.hasOwnProperty('created')">
|
||||
Added: {{(data.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" [disabled]="!isAdmin" (click)="updateCover()">Update Cover</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { Router } from '@angular/router';
|
||||
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Breakpoint, 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';
|
||||
@ -12,11 +12,16 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
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';
|
||||
import { LibraryType } from '../../../_models/library';
|
||||
import { LibraryService } from '../../../_services/library.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { PersonRole } from 'src/app/_models/person';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { MetadataService } from 'src/app/_services/metadata.service';
|
||||
|
||||
|
||||
|
||||
@ -30,38 +35,95 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
@Input() parentName = '';
|
||||
@Input() seriesId: number = 0;
|
||||
@Input() libraryId: number = 0;
|
||||
@Input() data!: any; // Volume | Chapter
|
||||
@Input() data!: Volume | Chapter; // Volume | Chapter
|
||||
|
||||
/**
|
||||
* If this is a volume, this will be first chapter for said volume.
|
||||
*/
|
||||
chapter!: Chapter;
|
||||
isChapter = false;
|
||||
chapters: Chapter[] = [];
|
||||
seriesVolumes: any[] = [];
|
||||
isLoadingVolumes = false;
|
||||
formatKeys = Object.keys(MangaFormat);
|
||||
|
||||
|
||||
/**
|
||||
* If a cover image update occured.
|
||||
*/
|
||||
coverImageUpdate: boolean = false;
|
||||
isAdmin: boolean = false;
|
||||
coverImageIndex: number = 0;
|
||||
/**
|
||||
* Url of the selected cover
|
||||
*/
|
||||
selectedCover: string = '';
|
||||
coverImageLocked: boolean = false;
|
||||
/**
|
||||
* When the API is doing work
|
||||
*/
|
||||
coverImageSaveLoading: boolean = false;
|
||||
imageUrls: Array<string> = [];
|
||||
|
||||
|
||||
actions: ActionItem<any>[] = [];
|
||||
chapterActions: ActionItem<Chapter>[] = [];
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
series: Series | undefined = undefined;
|
||||
|
||||
bookmarks: PageBookmark[] = [];
|
||||
|
||||
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Bookmarks', disabled: false}, {title: 'Info', disabled: false}];
|
||||
active = this.tabs[0];
|
||||
|
||||
chapterMetadata!: ChapterMetadata;
|
||||
ageRating!: string;
|
||||
|
||||
|
||||
get Breakpoint(): typeof Breakpoint {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
get PersonRole() {
|
||||
return PersonRole;
|
||||
}
|
||||
|
||||
get LibraryType(): typeof LibraryType {
|
||||
return LibraryType;
|
||||
}
|
||||
|
||||
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
|
||||
constructor(public modal: NgbActiveModal, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
|
||||
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
|
||||
private seriesService: SeriesService) { }
|
||||
private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.data);
|
||||
console.log('isChapter: ', this.isChapter);
|
||||
|
||||
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
|
||||
|
||||
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
||||
|
||||
let bookmarkApi;
|
||||
if (this.isChapter) {
|
||||
bookmarkApi = this.readerService.getBookmarks(this.chapter.id);
|
||||
} else {
|
||||
bookmarkApi = this.readerService.getBookmarksForVolume(this.data.id);
|
||||
}
|
||||
|
||||
bookmarkApi.pipe(take(1)).subscribe(bookmarks => {
|
||||
this.bookmarks = bookmarks;
|
||||
});
|
||||
|
||||
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||
this.chapterMetadata = metadata;
|
||||
|
||||
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
|
||||
});
|
||||
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
if (!this.accountService.hasAdminRole(user)) {
|
||||
this.tabs.find(s => s.title === 'Cover')!.disabled = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -72,10 +134,11 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit);
|
||||
|
||||
if (this.isChapter) {
|
||||
this.chapters.push(this.data);
|
||||
this.chapters.push(this.data as Chapter);
|
||||
} else if (!this.isChapter) {
|
||||
this.chapters.push(...this.data?.chapters);
|
||||
this.chapters.push(...(this.data as Volume).chapters);
|
||||
}
|
||||
// TODO: Move this into the backend
|
||||
this.chapters.sort(this.utilityService.sortChapters);
|
||||
this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id));
|
||||
// Try to show an approximation of the reading order for files
|
||||
@ -83,10 +146,6 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
this.chapters.forEach((c: Chapter) => {
|
||||
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
|
||||
});
|
||||
|
||||
this.seriesService.getSeries(this.seriesId).subscribe(series => {
|
||||
this.series = series;
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
@ -106,34 +165,36 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
chapter.coverImage = this.imageService.getChapterCoverImage(chapter.id);
|
||||
modalRef.componentInstance.chapter = chapter;
|
||||
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : this.utilityService.formatChapterName(this.libraryType, false, true)) + 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.');
|
||||
});
|
||||
} else {
|
||||
closeResult.chapter.coverImage = this.imageService.randomize(this.imageService.getChapterCoverImage(closeResult.chapter.id));
|
||||
updateSelectedIndex(index: number) {
|
||||
this.coverImageIndex = index;
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.coverImageLocked = false;
|
||||
}
|
||||
|
||||
saveCoverImage() {
|
||||
this.coverImageSaveLoading = true;
|
||||
const selectedIndex = this.coverImageIndex || 0;
|
||||
if (selectedIndex > 0) {
|
||||
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
|
||||
if (this.coverImageIndex > 0) {
|
||||
this.chapter.coverImageLocked = true;
|
||||
this.coverImageUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.coverImageSaveLoading = false;
|
||||
}, err => this.coverImageSaveLoading = false);
|
||||
} else if (this.coverImageLocked === false) {
|
||||
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
|
||||
this.toastr.info('Cover image reset');
|
||||
this.coverImageSaveLoading = false;
|
||||
this.coverImageUpdate = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
markChapterAsRead(chapter: Chapter) {
|
||||
@ -180,4 +241,10 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
|
||||
}
|
||||
}
|
||||
|
||||
removeBookmark(bookmark: PageBookmark, index: number) {
|
||||
this.readerService.unbookmark(bookmark.seriesId, bookmark.volumeId, bookmark.chapterId, bookmark.page).subscribe(() => {
|
||||
this.bookmarks.splice(index, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +0,0 @@
|
||||
<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>
|
@ -1,3 +0,0 @@
|
||||
.scrollable-modal {
|
||||
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
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});
|
||||
}
|
||||
|
||||
}
|
@ -7,82 +7,321 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
|
||||
<form [formGroup]="editSeriesForm">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<form [formGroup]="editSeriesForm">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<input id="name" class="form-control" formControlName="name" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">Sort Name</label>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="localized-name" class="form-label">Localized Name</label>
|
||||
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="author" class="form-label">Author</label>
|
||||
<input id="author" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="author" type="text">
|
||||
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<div class="input-group {{series.nameLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'nameLocked' }"></ng-container>
|
||||
<input id="name" class="form-control" formControlName="name" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="artist" class="form-label">Artist</label>
|
||||
<input id="artist" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="artist" type="text">
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">Sort Name</label>
|
||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="genres" class="form-label">Genres</label>
|
||||
<input id="genres" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="genres" type="text">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="localized-name" class="form-label">Localized Name</label>
|
||||
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
|
||||
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="collections" class="form-label">Collections</label>
|
||||
<app-typeahead (selectedData)="updateCollections($event)" [settings]="settings">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
|
||||
<div class="row g-0" *ngIf="metadata">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
<div class="input-group {{metadata?.summaryLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{tabs[1]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]">
|
||||
<a ngbNavLink>{{tabs[1]}}</a>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="collections" class="form-label">Collections </label>
|
||||
<app-typeahead (selectedData)="updateCollections($event)" [settings]="collectionTagSettings" [locked]="true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="genres" class="form-label">Genres</label>
|
||||
<app-typeahead (selectedData)="updateGenres($event)" [settings]="genreSettings"
|
||||
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
|
||||
(newItemAdded)="metadata.genresLocked = true" (selectedData)="metadata.genresLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">Tags</label>
|
||||
<app-typeahead (selectedData)="updateTags($event)" [settings]="tagsSettings"
|
||||
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
|
||||
(newItemAdded)="metadata.tagsLocked = true" (selectedData)="metadata.tagsLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="language" class="form-label">Language</label>
|
||||
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
|
||||
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
|
||||
(newItemAdded)="metadata.languageLocked = true" (selectedData)="metadata.languageLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}} ({{item.isoCode}})
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="age-rating" class="form-label">Age Rating</label>
|
||||
<div class="input-group {{metadata.ageRatingLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'ageRatingLocked' }"></ng-container>
|
||||
<select class="form-select"id="age-rating" formControlName="ageRating">
|
||||
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="publication-status" class="form-label">Publication Status</label>
|
||||
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'publicationStatusLocked' }"></ng-container>
|
||||
<select class="form-select"id="publication-status" formControlName="publicationStatus">
|
||||
<option *ngFor="let opt of publicationStatuses" [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[2]">
|
||||
<a ngbNavLink>{{tabs[2]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="writer" class="form-label">Writer</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer)" [settings]="getPersonsSettings(PersonRole.Writer)"
|
||||
[(locked)]="metadata.writersLocked" (onUnlock)="metadata.writersLocked = false"
|
||||
(newItemAdded)="metadata.writersLocked = true" (selectedData)="metadata.writersLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="cover-artist" class="form-label">Cover Artist</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist)" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
||||
[(locked)]="metadata.coverArtistsLocked" (onUnlock)="metadata.coverArtistsLocked = false"
|
||||
(newItemAdded)="metadata.coverArtistsLocked = true" (selectedData)="metadata.coverArtistsLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="publisher" class="form-label">Publisher</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher)" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
||||
[(locked)]="metadata.publishersLocked" (onUnlock)="metadata.publishersLocked = false"
|
||||
(newItemAdded)="metadata.publishersLocked = true" (selectedData)="metadata.publishersLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="penciller" class="form-label">Penciller</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller)" [settings]="getPersonsSettings(PersonRole.Penciller)"
|
||||
[(locked)]="metadata.pencillersLocked" (onUnlock)="metadata.pencillersLocked = false"
|
||||
(newItemAdded)="metadata.pencillersLocked = true" (selectedData)="metadata.pencillersLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="letterer" class="form-label">Letterer</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer)" [settings]="getPersonsSettings(PersonRole.Letterer)"
|
||||
[(locked)]="metadata.letterersLocked" (onUnlock)="metadata.letterersLocked = false"
|
||||
(newItemAdded)="metadata.letterersLocked = true" (selectedData)="metadata.letterersLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="inker" class="form-label">Inker</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker)" [settings]="getPersonsSettings(PersonRole.Inker)"
|
||||
[(locked)]="metadata.inkersLocked" (onUnlock)="metadata.inkersLocked = false"
|
||||
(newItemAdded)="metadata.inkersLocked = true" (selectedData)="metadata.inkersLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="editor" class="form-label">Editor</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor)" [settings]="getPersonsSettings(PersonRole.Editor)"
|
||||
[(locked)]="metadata.editorsLocked" (onUnlock)="metadata.editorsLocked = false"
|
||||
(newItemAdded)="metadata.editorsLocked = true" (selectedData)="metadata.editorsLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="colorist" class="form-label">Colorist</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist)" [settings]="getPersonsSettings(PersonRole.Colorist)"
|
||||
[(locked)]="metadata.coloristsLocked" (onUnlock)="metadata.coloristsLocked = false"
|
||||
(newItemAdded)="metadata.coloristsLocked = true" (selectedData)="metadata.coloristsLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="character" class="form-label">Character</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character)" [settings]="getPersonsSettings(PersonRole.Character)"
|
||||
[(locked)]="metadata.charactersLocked" (onUnlock)="metadata.charactersLocked = false"
|
||||
(newItemAdded)="metadata.charactersLocked = true" (selectedData)="metadata.charactersLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="translator" class="form-label">Translators</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator)" [settings]="getPersonsSettings(PersonRole.Translator)"
|
||||
[(locked)]="metadata.translatorsLocked" (onUnlock)="metadata.translatorsLocked = false"
|
||||
(newItemAdded)="metadata.translatorsLocked = true" (selectedData)="metadata.translatorsLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[3]">
|
||||
<a ngbNavLink>{{tabs[3]}}</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.
|
||||
@ -90,8 +329,8 @@
|
||||
<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[2]">
|
||||
<a ngbNavLink>{{tabs[2]}}</a>
|
||||
<li [ngbNavItem]="tabs[4]">
|
||||
<a ngbNavLink>{{tabs[4]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4>Information</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
@ -152,8 +391,9 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
|
||||
@ -162,3 +402,10 @@
|
||||
</div>
|
||||
|
||||
|
||||
<ng-template #lock let-item="item" let-field="field">
|
||||
<span class="input-group-text clickable" (click)="unlock(item, field)">
|
||||
<i class="fa fa-lock" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Field is locked</span>
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
|
@ -2,3 +2,10 @@
|
||||
max-height: 90vh; // 600px
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.lock-active {
|
||||
> .input-group-text {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
}
|
@ -1,17 +1,24 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { forkJoin, Observable, of, Subject } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Genre } from 'src/app/_models/genre';
|
||||
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
|
||||
import { Language } from 'src/app/_models/metadata/language';
|
||||
import { PublicationStatusDto } from 'src/app/_models/metadata/publication-status-dto';
|
||||
import { Person, PersonRole } from 'src/app/_models/person';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { SeriesMetadata } from 'src/app/_models/series-metadata';
|
||||
import { Tag } from 'src/app/_models/tag';
|
||||
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 { MetadataService } from 'src/app/_services/metadata.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
@ -28,14 +35,27 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
|
||||
isCollapsed = true;
|
||||
volumeCollapsed: any = {};
|
||||
tabs = ['General', 'Cover Image', 'Info'];
|
||||
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Info'];
|
||||
active = this.tabs[0];
|
||||
editSeriesForm!: FormGroup;
|
||||
libraryName: string | undefined = undefined;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
settings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
tags: CollectionTag[] = [];
|
||||
|
||||
// Typeaheads
|
||||
ageRatingSettings: TypeaheadSettings<AgeRatingDto> = new TypeaheadSettings();
|
||||
publicationStatusSettings: TypeaheadSettings<PublicationStatusDto> = new TypeaheadSettings();
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
collectionTagSettings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
|
||||
|
||||
collectionTags: CollectionTag[] = [];
|
||||
tags: Tag[] = [];
|
||||
genres: Genre[] = [];
|
||||
|
||||
metadata!: SeriesMetadata;
|
||||
imageUrls: Array<string> = [];
|
||||
/**
|
||||
@ -43,10 +63,22 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
selectedCover: string = '';
|
||||
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
publicationStatuses: Array<PublicationStatusDto> = [];
|
||||
validLanguages: Array<Language> = [];
|
||||
|
||||
get Breakpoint(): typeof Breakpoint {
|
||||
return Breakpoint;
|
||||
}
|
||||
|
||||
get PersonRole() {
|
||||
return PersonRole;
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
}
|
||||
|
||||
constructor(public modal: NgbActiveModal,
|
||||
private seriesService: SeriesService,
|
||||
public utilityService: UtilityService,
|
||||
@ -54,7 +86,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
public imageService: ImageService,
|
||||
private libraryService: LibraryService,
|
||||
private collectionService: CollectionTagService,
|
||||
private uploadService: UploadService) { }
|
||||
private uploadService: UploadService,
|
||||
private metadataService: MetadataService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
|
||||
@ -63,9 +96,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
this.libraryName = names[this.series.libraryId];
|
||||
});
|
||||
|
||||
this.setupTypeaheadSettings();
|
||||
|
||||
|
||||
|
||||
this.editSeriesForm = this.fb.group({
|
||||
id: new FormControl(this.series.id, []),
|
||||
@ -75,20 +105,70 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
sortName: new FormControl(this.series.sortName, []),
|
||||
rating: new FormControl(this.series.userRating, []),
|
||||
|
||||
genres: new FormControl('', []),
|
||||
author: new FormControl('', []),
|
||||
artist: new FormControl('', []),
|
||||
|
||||
coverImageIndex: new FormControl(0, []),
|
||||
coverImageLocked: new FormControl(this.series.coverImageLocked, [])
|
||||
coverImageLocked: new FormControl(this.series.coverImageLocked, []),
|
||||
|
||||
ageRating: new FormControl('', []),
|
||||
publicationStatus: new FormControl('', []),
|
||||
language: new FormControl('', []),
|
||||
});
|
||||
|
||||
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings = ratings;
|
||||
});
|
||||
|
||||
this.metadataService.getAllPublicationStatus().subscribe(statuses => {
|
||||
this.publicationStatuses = statuses;
|
||||
});
|
||||
|
||||
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
|
||||
this.validLanguages = validLanguages;
|
||||
})
|
||||
|
||||
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
|
||||
if (metadata) {
|
||||
this.metadata = metadata;
|
||||
this.settings.savedData = metadata.collectionTags;
|
||||
this.tags = metadata.collectionTags;
|
||||
|
||||
this.setupTypeaheads();
|
||||
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
||||
this.editSeriesForm.get('ageRating')?.setValue(this.metadata.ageRating);
|
||||
this.editSeriesForm.get('publicationStatus')?.setValue(this.metadata.publicationStatus);
|
||||
this.editSeriesForm.get('language')?.setValue(this.metadata.language);
|
||||
|
||||
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
if (!this.editSeriesForm.get('name')?.touched) return;
|
||||
this.series.nameLocked = true;
|
||||
});
|
||||
|
||||
this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
if (!this.editSeriesForm.get('sortName')?.touched) return;
|
||||
this.series.sortNameLocked = true;
|
||||
});
|
||||
|
||||
this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
if (!this.editSeriesForm.get('localizedName')?.touched) return;
|
||||
this.series.localizedNameLocked = true;
|
||||
});
|
||||
|
||||
this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
if (!this.editSeriesForm.get('summary')?.touched) return;
|
||||
this.metadata.summaryLocked = true;
|
||||
this.metadata.summary = val;
|
||||
});
|
||||
|
||||
|
||||
this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
this.metadata.ageRating = parseInt(val + '', 10);
|
||||
if (!this.editSeriesForm.get('ageRating')?.touched) return;
|
||||
this.metadata.ageRatingLocked = true;
|
||||
});
|
||||
|
||||
this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
this.metadata.publicationStatus = parseInt(val + '', 10);
|
||||
if (!this.editSeriesForm.get('publicationStatus')?.touched) return;
|
||||
this.metadata.publicationStatusLocked = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -114,22 +194,192 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
setupTypeaheadSettings() {
|
||||
this.settings.minCharacters = 0;
|
||||
this.settings.multiple = true;
|
||||
this.settings.id = 'collections';
|
||||
this.settings.unique = true;
|
||||
this.settings.addIfNonExisting = true;
|
||||
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.settings.compareFn(items, filter)));
|
||||
this.settings.addTransformFn = ((title: string) => {
|
||||
setupTypeaheads() {
|
||||
forkJoin([
|
||||
this.setupCollectionTagsSettings(),
|
||||
this.setupTagSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
this.setupLanguageTypeahead()
|
||||
]).subscribe(results => {
|
||||
this.collectionTags = this.metadata.collectionTags;
|
||||
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
|
||||
});
|
||||
}
|
||||
|
||||
setupCollectionTagsSettings() {
|
||||
this.collectionTagSettings.minCharacters = 0;
|
||||
this.collectionTagSettings.multiple = true;
|
||||
this.collectionTagSettings.id = 'collections';
|
||||
this.collectionTagSettings.unique = true;
|
||||
this.collectionTagSettings.addIfNonExisting = true;
|
||||
this.collectionTagSettings.fetchFn = (filter: string) => this.fetchCollectionTags(filter).pipe(map(items => this.collectionTagSettings.compareFn(items, filter)));
|
||||
this.collectionTagSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
|
||||
});
|
||||
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
this.collectionTagSettings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.settings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
this.collectionTagSettings.singleCompareFn = (a: CollectionTag, b: CollectionTag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.metadata.collectionTags) {
|
||||
this.collectionTagSettings.savedData = this.metadata.collectionTags;
|
||||
}
|
||||
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupTagSettings() {
|
||||
this.tagsSettings.minCharacters = 0;
|
||||
this.tagsSettings.multiple = true;
|
||||
this.tagsSettings.id = 'tags';
|
||||
this.tagsSettings.unique = true;
|
||||
this.tagsSettings.showLocked = true;
|
||||
this.tagsSettings.addIfNonExisting = true;
|
||||
|
||||
|
||||
this.tagsSettings.compareFn = (options: Tag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.tagsSettings.fetchFn = (filter: string) => this.metadataService.getAllTags()
|
||||
.pipe(map(items => this.tagsSettings.compareFn(items, filter)));
|
||||
|
||||
this.tagsSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title };
|
||||
});
|
||||
this.tagsSettings.singleCompareFn = (a: Tag, b: Tag) => {
|
||||
return a.id == b.id;
|
||||
}
|
||||
|
||||
if (this.metadata.tags) {
|
||||
this.tagsSettings.savedData = this.metadata.tags;
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupGenreTypeahead() {
|
||||
this.genreSettings.minCharacters = 0;
|
||||
this.genreSettings.multiple = true;
|
||||
this.genreSettings.id = 'genres';
|
||||
this.genreSettings.unique = true;
|
||||
this.genreSettings.showLocked = true;
|
||||
this.genreSettings.addIfNonExisting = true;
|
||||
this.genreSettings.fetchFn = (filter: string) => {
|
||||
return this.metadataService.getAllGenres()
|
||||
.pipe(map(items => this.genreSettings.compareFn(items, filter)));
|
||||
};
|
||||
this.genreSettings.compareFn = (options: Genre[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.genreSettings.singleCompareFn = (a: Genre, b: Genre) => {
|
||||
return a.title == b.title;
|
||||
}
|
||||
|
||||
this.genreSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title };
|
||||
});
|
||||
|
||||
if (this.metadata.genres) {
|
||||
this.genreSettings.savedData = this.metadata.genres;
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
updateFromPreset(id: string, presetField: Array<Person> | undefined, role: PersonRole) {
|
||||
const personSettings = this.createBlankPersonSettings(id, role)
|
||||
if (presetField && presetField.length > 0) {
|
||||
const fetch = personSettings.fetchFn as ((filter: string) => Observable<Person[]>);
|
||||
return fetch('').pipe(map(people => {
|
||||
const persetIds = presetField.map(p => p.id);
|
||||
personSettings.savedData = people.filter(person => persetIds.includes(person.id));
|
||||
this.peopleSettings[role] = personSettings;
|
||||
this.updatePerson(personSettings.savedData as Person[], role);
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupLanguageTypeahead() {
|
||||
this.languageSettings.minCharacters = 0;
|
||||
this.languageSettings.multiple = false;
|
||||
this.languageSettings.id = 'language';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.showLocked = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.singleCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
if (this.metadata.language) {
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupPersonTypeahead() {
|
||||
this.peopleSettings = {};
|
||||
|
||||
return forkJoin([
|
||||
this.updateFromPreset('writer', this.metadata.writers, PersonRole.Writer),
|
||||
this.updateFromPreset('character', this.metadata.characters, PersonRole.Character),
|
||||
this.updateFromPreset('colorist', this.metadata.colorists, PersonRole.Colorist),
|
||||
this.updateFromPreset('cover-artist', this.metadata.coverArtists, PersonRole.CoverArtist),
|
||||
this.updateFromPreset('editor', this.metadata.editors, PersonRole.Editor),
|
||||
this.updateFromPreset('inker', this.metadata.inkers, PersonRole.Inker),
|
||||
this.updateFromPreset('letterer', this.metadata.letterers, PersonRole.Letterer),
|
||||
this.updateFromPreset('penciller', this.metadata.pencillers, PersonRole.Penciller),
|
||||
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
|
||||
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
|
||||
]).pipe(map(results => {
|
||||
//this.resetTypeaheads.next(true);
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
|
||||
fetchPeople(role: PersonRole, filter: string) {
|
||||
return this.metadataService.getAllPeople().pipe(map(people => {
|
||||
return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter));
|
||||
}));
|
||||
}
|
||||
|
||||
createBlankPersonSettings(id: string, role: PersonRole) {
|
||||
var personSettings = new TypeaheadSettings<Person>();
|
||||
personSettings.minCharacters = 0;
|
||||
personSettings.multiple = true;
|
||||
personSettings.showLocked = true;
|
||||
personSettings.unique = true;
|
||||
personSettings.addIfNonExisting = true;
|
||||
personSettings.id = id;
|
||||
personSettings.compareFn = (options: Person[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.name, filter));
|
||||
}
|
||||
|
||||
personSettings.singleCompareFn = (a: Person, b: Person) => {
|
||||
return a.name == b.name && a.role == b.role;
|
||||
}
|
||||
personSettings.fetchFn = (filter: string) => {
|
||||
return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter)));
|
||||
};
|
||||
|
||||
personSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, name: title, role: role };
|
||||
});
|
||||
|
||||
return personSettings;
|
||||
}
|
||||
|
||||
close() {
|
||||
@ -150,11 +400,17 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
save() {
|
||||
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)
|
||||
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
|
||||
];
|
||||
|
||||
// We only need to call updateSeries if we changed name, sort name, or localized name
|
||||
if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty) {
|
||||
apis.push(this.seriesService.updateSeries(model));
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (selectedIndex > 0) {
|
||||
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
|
||||
@ -165,8 +421,65 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
handleUnlock(field: string) {
|
||||
console.log('todo: unlock ', field);
|
||||
}
|
||||
|
||||
hello(val: boolean) {
|
||||
console.log('hello: ', val);
|
||||
}
|
||||
|
||||
updateCollections(tags: CollectionTag[]) {
|
||||
this.collectionTags = tags;
|
||||
}
|
||||
|
||||
updateTags(tags: Tag[]) {
|
||||
this.tags = tags;
|
||||
this.metadata.tags = tags;
|
||||
}
|
||||
|
||||
updateGenres(genres: Genre[]) {
|
||||
this.genres = genres;
|
||||
this.metadata.genres = genres;
|
||||
}
|
||||
|
||||
updateLanguage(language: Language) {
|
||||
this.metadata.language = language.isoCode;
|
||||
}
|
||||
|
||||
updatePerson(persons: Person[], role: PersonRole) {
|
||||
switch (role) {
|
||||
case PersonRole.CoverArtist:
|
||||
this.metadata.coverArtists = persons;
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.metadata.characters = persons;
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.metadata.colorists = persons;
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.metadata.editors = persons;
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.metadata.inkers = persons;
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.metadata.letterers = persons;
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.metadata.pencillers = persons;
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.metadata.publishers = persons;
|
||||
break;
|
||||
case PersonRole.Writer:
|
||||
this.metadata.writers = persons;
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.metadata.translators = persons;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
@ -185,4 +498,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
unlock(b: any, field: string) {
|
||||
if (b) {
|
||||
b[field] = !b[field];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ 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 { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
|
||||
@ -34,7 +33,6 @@ import { BookmarkComponent } from './bookmark/bookmark.component';
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
ChangeCoverImageModalComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
@ -75,7 +73,6 @@ import { BookmarkComponent } from './bookmark/bookmark.component';
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
ChangeCoverImageModalComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
|
@ -1,118 +1,102 @@
|
||||
<ng-container *ngIf="chapter !== undefined">
|
||||
<div class="container-fluid">
|
||||
<!-- <h4>{{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}} <span title="Id">({{chapter.id}})</span></h4> -->
|
||||
|
||||
|
||||
<!-- Arc Information -->
|
||||
<ng-container>
|
||||
<span *ngIf="chapter.writers.length === 0 && chapter.coverArtists.length === 0
|
||||
&& chapter.pencillers.length === 0 && chapter.inkers.length === 0
|
||||
&& chapter.colorists.length === 0 && chapter.letterers.length === 0
|
||||
&& chapter.editors.length === 0 && chapter.publishers.length === 0
|
||||
&& chapter.characters.length === 0 && chapter.translators.length === 0">
|
||||
No metadata available
|
||||
</span>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Id: {{chapter.id}}
|
||||
<div class="col-auto" *ngIf="chapter.writers && chapter.writers.length > 0">
|
||||
<h6>Writers</h6>
|
||||
<app-badge-expander [items]="chapter.writers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Title: {{chapter.titleName || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{chapter.pages}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
|
||||
Added: {{(chapter.created | date: 'short') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Release Date: {{(chapter.releaseDate | date: 'shortDate') || '-'}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="list-unstyled" >
|
||||
<li class="d-flex my-4">
|
||||
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
|
||||
<app-image class="me-3" width="74px" [imageUrl]="chapter.coverImage"></app-image>
|
||||
</a>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
<!-- TODO: Add back in
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions" [labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
|
||||
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
|
||||
</span> -->
|
||||
<span class="badge bg-primary rounded-pill">
|
||||
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
|
||||
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
|
||||
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
|
||||
</span>
|
||||
</span>
|
||||
<ng-template #specialHeader>Files</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group file-list">
|
||||
<app-file-info *ngFor="let file of chapter.files" [file]="file" [created]="chapter.created"></app-file-info>
|
||||
</ul>
|
||||
|
||||
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-1" *ngIf="chapter.writers && chapter.writers.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Writers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of chapter.writers" [person]="person"></app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-1" *ngIf="chapter.coverArtist && chapter.coverArtist.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Artists</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of chapter.coverArtist" [person]="person"></app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-1" *ngIf="chapter.publisher && chapter.publisher.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Publishers</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<app-person-badge *ngFor="let person of chapter.publisher" [person]="person"></app-person-badge>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="col-auto" *ngIf="chapter.coverArtists && chapter.coverArtists.length > 0">
|
||||
<h6>Cover Artists</h6>
|
||||
<app-badge-expander [items]="chapter.coverArtists">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
||||
<div class="col-auto" *ngIf="chapter.pencillers && chapter.pencillers.length > 0">
|
||||
<h6>Pencillers</h6>
|
||||
<app-badge-expander [items]="chapter.pencillers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
||||
<div class="col-auto" *ngIf="chapter.inkers && chapter.inkers.length > 0">
|
||||
<h6>Inkers</h6>
|
||||
<app-badge-expander [items]="chapter.inkers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
||||
<div class="col-auto" *ngIf="chapter.colorists && chapter.colorists.length > 0">
|
||||
<h6>Colorists</h6>
|
||||
<app-badge-expander [items]="chapter.colorists">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<!--
|
||||
<div class="col-auto" *ngIf="chapter.letterers && chapter.letterers.length > 0">
|
||||
<h6>Letterers</h6>
|
||||
<app-badge-expander [items]="chapter.letterers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
||||
<div class="container-fluid" *ngIf="metadata !== undefined">
|
||||
Chapter {{chapter.range}} {{metadata.title.length > 0 ? ' - ' + metadata.title : ''}}
|
||||
Title: {{metadata.title || '-'}}
|
||||
Year: {{metadata.year || '-'}}
|
||||
Arc Information
|
||||
|
||||
<div class="col-auto" *ngIf="chapter.editors && chapter.editors.length > 0">
|
||||
<h6>Editors</h6>
|
||||
<app-badge-expander [items]="chapter.editors">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
Id: {{chapter.id}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{chapter.pages}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto" *ngIf="chapter.publishers && chapter.publishers.length > 0">
|
||||
<h6>Publishers</h6>
|
||||
<app-badge-expander [items]="chapter.publishers">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col" *ngIf="chapter.hasOwnProperty('created')">
|
||||
Added: {{(chapter.created | date: 'short') || '-'}}
|
||||
<div class="col-auto" *ngIf="chapter.characters && chapter.characters.length > 0">
|
||||
<h6>Characters</h6>
|
||||
<app-badge-expander [items]="chapter.characters">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
<div class="col-auto" *ngIf="chapter.translators && chapter.translators.length > 0">
|
||||
<h6>Translators</h6>
|
||||
<app-badge-expander [items]="chapter.translators">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||
<app-person-badge [person]="item"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{chapter.pages}}
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</ng-container>
|
||||
</ng-container>
|
@ -5,6 +5,7 @@ import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { LibraryType } from 'src/app/_models/library';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { PersonRole } from 'src/app/_models/person';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-metadata-detail',
|
||||
@ -13,9 +14,10 @@ import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
})
|
||||
export class ChapterMetadataDetailComponent implements OnInit {
|
||||
|
||||
@Input() chapter!: Chapter;
|
||||
@Input() chapter!: ChapterMetadata;
|
||||
@Input() libraryType: LibraryType = LibraryType.Manga;
|
||||
//metadata!: ChapterMetadata;
|
||||
|
||||
roles: string[] = [];
|
||||
|
||||
get LibraryType(): typeof LibraryType {
|
||||
return LibraryType;
|
||||
@ -24,10 +26,14 @@ export class ChapterMetadataDetailComponent implements OnInit {
|
||||
constructor(private metadataService: MetadataService, public utilityService: UtilityService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.metadataService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||
// console.log('Chapter ', this.chapter.number, ' metadata: ', metadata);
|
||||
// this.metadata = metadata;
|
||||
// })
|
||||
this.roles = Object.keys(PersonRole).filter(role => /[0-9]/.test(role) === false);
|
||||
}
|
||||
|
||||
getPeople(role: string) {
|
||||
if (this.chapter) {
|
||||
return (this.chapter as any)[role.toLowerCase()];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Chapter>, chapter: Chapter) {
|
||||
|
@ -24,12 +24,13 @@
|
||||
</ng-container>
|
||||
|
||||
<div (click)="toggleMenu()" class="reading-area">
|
||||
|
||||
<!-- TODO: Change this logic to only render for split pages and use image for all else-->
|
||||
<canvas style="display: none;" #content class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}"
|
||||
ondragstart="return false;" onselectstart="return false;">
|
||||
</canvas>
|
||||
<div *ngIf="isCoverImage && shouldRenderAsFitSplit()" class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
|
||||
<img [src]="canvasImage.src" style="width: 100%">
|
||||
|
||||
<div *ngIf="isCoverImage && shouldRenderAsFitSplit()">
|
||||
<img [src]="canvasImage.src" class="{{getFittingOptionClass()}} {{readerMode === READER_MODE.MANGA_LR || readerMode === READER_MODE.MANGA_UD ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
|
||||
</div>
|
||||
<div class="webtoon-images" *ngIf="readerMode === READER_MODE.WEBTOON && !isLoading && !inSetup">
|
||||
<app-infinite-scroller [pageNum]="pageNum"
|
||||
|
@ -1,18 +1,24 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FilterPipe } from './filter.pipe';
|
||||
import { PersonRolePipe } from './person-role.pipe';
|
||||
import { PublicationStatusPipe } from './publication-status.pipe';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FilterPipe
|
||||
FilterPipe,
|
||||
PersonRolePipe,
|
||||
PublicationStatusPipe
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
exports: [
|
||||
FilterPipe
|
||||
FilterPipe,
|
||||
PersonRolePipe,
|
||||
PublicationStatusPipe
|
||||
]
|
||||
})
|
||||
export class PipeModule { }
|
||||
|
@ -43,7 +43,10 @@ export class ThemeTestComponent implements OnInit {
|
||||
sortName: '',
|
||||
userRating: 0,
|
||||
userReview: '',
|
||||
volumes: []
|
||||
volumes: [],
|
||||
localizedNameLocked: false,
|
||||
nameLocked: false,
|
||||
sortNameLocked: false
|
||||
}
|
||||
|
||||
seriesWithProgress: Series = {
|
||||
@ -61,7 +64,10 @@ export class ThemeTestComponent implements OnInit {
|
||||
sortName: '',
|
||||
userRating: 0,
|
||||
userReview: '',
|
||||
volumes: []
|
||||
volumes: [],
|
||||
localizedNameLocked: false,
|
||||
nameLocked: false,
|
||||
sortNameLocked: false
|
||||
}
|
||||
|
||||
get TagBadgeCursor(): typeof TagBadgeCursor {
|
||||
|
@ -16,6 +16,10 @@ export class TypeaheadSettings<T> {
|
||||
* Id of the input element, for linking label elements (accessibility)
|
||||
*/
|
||||
id: string = '';
|
||||
/**
|
||||
* Show a locked icon next to input and provide functionality around locking/unlocking a field
|
||||
*/
|
||||
showLocked: boolean = false;
|
||||
/**
|
||||
* Data to preload the typeahead with on first load
|
||||
*/
|
||||
|
@ -1,36 +1,40 @@
|
||||
<form [formGroup]="typeaheadForm">
|
||||
<ng-container *ngIf="settings.multiple" >
|
||||
<div class="typeahead-input" (click)="onInputFocus($event)">
|
||||
<div>
|
||||
<app-tag-badge *ngFor="let option of optionSelection.selected(); let i = index">
|
||||
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i }"></ng-container>
|
||||
<i class="fa fa-times" (click)="toggleSelection(option)" tabindex="0" aria-label="close"></i>
|
||||
</app-tag-badge>
|
||||
<div class="input-group {{hasFocus ? 'open': ''}} {{locked ? 'lock-active' : ''}}">
|
||||
<ng-container *ngIf="settings.showLocked">
|
||||
<span class="input-group-text clickable" (click)="unlock($event)"><i class="fa fa-lock" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Field is locked</span>
|
||||
</span>
|
||||
</ng-container>
|
||||
<div class="typeahead-input" [ngStyle]="{'width': (settings.showLocked ? '93%' : '100%')}" (click)="onInputFocus($event)">
|
||||
<app-tag-badge *ngFor="let option of optionSelection.selected(); let i = index">
|
||||
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i }"></ng-container>
|
||||
<i class="fa fa-times" (click)="toggleSelection(option)" tabindex="0" aria-label="close"></i>
|
||||
</app-tag-badge>
|
||||
|
||||
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead">
|
||||
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoadingOptions">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<!-- TODO: Add a clear all button -->
|
||||
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead">
|
||||
<div class="spinner-border spinner-border-sm {{settings.multiple ? 'close-offset' : ''}}" role="status" *ngIf="isLoadingOptions">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown" *ngIf="hasFocus">
|
||||
<ul class="list-group results" #results>
|
||||
<li *ngIf="showAddItem"
|
||||
class="list-group-item add-item" role="option" (mouseenter)="focusedIndex = 0; updateHighlight();" (click)="addNewItem(typeaheadControl.value)">
|
||||
Add {{typeaheadControl.value}}...
|
||||
</li>
|
||||
<li *ngFor="let option of filteredOptions | async; let index = index;" (click)="handleOptionClick(option)"
|
||||
class="list-group-item" role="option"
|
||||
(mouseenter)="focusedIndex = index + (showAddItem ? 1 : 0); updateHighlight();">
|
||||
<ng-container [ngTemplateOutlet]="optionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
<li *ngIf="(filteredOptions | async)?.length === 0 && !showAddItem" class="list-group-item no-hover" role="status">
|
||||
No data{{this.settings.addIfNonExisting ? ', type to add a custom item.' : '.'}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ng-container *ngIf="settings.multiple">
|
||||
<button class="btn btn-close float-end mt-2" style="font-size: 0.8rem;" (click)="clearSelections($event)"></button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-container>
|
||||
</form>
|
||||
<div class="dropdown" *ngIf="hasFocus">
|
||||
<ul class="list-group results" #results>
|
||||
<li *ngIf="showAddItem"
|
||||
class="list-group-item add-item" role="option" (mouseenter)="focusedIndex = 0; updateHighlight();" (click)="addNewItem(typeaheadControl.value)">
|
||||
Add {{typeaheadControl.value}}...
|
||||
</li>
|
||||
<li *ngFor="let option of filteredOptions | async; let index = index;" (click)="handleOptionClick(option)"
|
||||
class="list-group-item" role="option"
|
||||
(mouseenter)="focusedIndex = index + (showAddItem ? 1 : 0); updateHighlight();">
|
||||
<ng-container [ngTemplateOutlet]="optionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
|
||||
</li>
|
||||
<li *ngIf="(filteredOptions | async)?.length === 0 && !showAddItem" class="list-group-item no-hover" role="status">
|
||||
No data{{this.settings.addIfNonExisting ? ', type to add a custom item.' : '.'}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
@ -2,6 +2,10 @@ form {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex-wrap: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 15px;
|
||||
opacity: 1px;
|
||||
@ -10,6 +14,18 @@ input {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.lock-active {
|
||||
> .input-group-text {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.close-offset {
|
||||
right: 29px !important;
|
||||
top: 29% !important;
|
||||
}
|
||||
|
||||
.typeahead-input {
|
||||
padding: 0px 6px;
|
||||
display: inline-block;
|
||||
@ -44,14 +60,22 @@ input {
|
||||
}
|
||||
}
|
||||
|
||||
.open .input-group-text {
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
|
||||
.open .typeahead-input {
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
width: 100%;
|
||||
min-width: 10rem;
|
||||
background: var(--input-bg-color);
|
||||
z-index:1000;
|
||||
margin: 2px 0 0;
|
||||
z-index: 1000;
|
||||
border-radius: 4px;
|
||||
margin-top: -7px;
|
||||
margin-top: -1px;
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
position: absolute;
|
||||
@ -59,6 +83,11 @@ input {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
.list-group {
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
|
@ -137,30 +137,36 @@ export class SelectionModel<T> {
|
||||
styleUrls: ['./typeahead.component.scss']
|
||||
})
|
||||
export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||
|
||||
filteredOptions!: Observable<string[]>;
|
||||
isLoadingOptions: boolean = false;
|
||||
typeaheadControl!: FormControl;
|
||||
typeaheadForm!: FormGroup;
|
||||
|
||||
|
||||
/**
|
||||
* Settings for the typeahead
|
||||
*/
|
||||
@Input() settings!: TypeaheadSettings<any>;
|
||||
/**
|
||||
* When true, component will re-init and set back to false.
|
||||
*/
|
||||
@Input() reset: Subject<boolean> = new ReplaySubject(1);
|
||||
/**
|
||||
* When a field is locked, we render custom css to indicate to the user. Does not affect functionality.
|
||||
*/
|
||||
@Input() locked: boolean = false;
|
||||
@Output() selectedData = new EventEmitter<any[] | any>();
|
||||
@Output() newItemAdded = new EventEmitter<any[] | any>();
|
||||
@Output() onUnlock = new EventEmitter<void>();
|
||||
@Output() lockedChange = new EventEmitter<boolean>();
|
||||
|
||||
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
|
||||
@ContentChild('optionItem') optionTemplate!: TemplateRef<any>;
|
||||
@ContentChild('badgeItem') badgeTemplate!: TemplateRef<any>;
|
||||
|
||||
optionSelection!: SelectionModel<any>;
|
||||
|
||||
hasFocus = false; // Whether input has active focus
|
||||
focusedIndex: number = 0;
|
||||
showAddItem: boolean = false;
|
||||
|
||||
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
|
||||
@ContentChild('optionItem') optionTemplate!: TemplateRef<any>;
|
||||
@ContentChild('badgeItem') badgeTemplate!: TemplateRef<any>;
|
||||
filteredOptions!: Observable<string[]>;
|
||||
isLoadingOptions: boolean = false;
|
||||
typeaheadControl!: FormControl;
|
||||
typeaheadForm!: FormGroup;
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
@ -245,10 +251,17 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||
if (this.settings.multiple) {
|
||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
||||
}
|
||||
// else {
|
||||
// this.optionSelection = new SelectionModel<any>(true, this.settings.savedData[0]);
|
||||
// this.typeaheadControl.setValue(this.settings.displayFn(this.settings.savedData))
|
||||
// }
|
||||
else {
|
||||
const isArray = this.settings.savedData.hasOwnProperty('length');
|
||||
if (isArray) {
|
||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
||||
} else {
|
||||
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
|
||||
}
|
||||
|
||||
|
||||
//this.typeaheadControl.setValue(this.settings.displayFn(this.settings.savedData))
|
||||
}
|
||||
} else {
|
||||
this.optionSelection = new SelectionModel<any>();
|
||||
}
|
||||
@ -336,7 +349,17 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||
this.resetField();
|
||||
}
|
||||
|
||||
clearSelections(event: any) {
|
||||
this.optionSelection.selected().forEach(item => this.optionSelection.toggle(item, false));
|
||||
this.selectedData.emit(this.optionSelection.selected());
|
||||
this.resetField();
|
||||
}
|
||||
|
||||
handleOptionClick(opt: any) {
|
||||
if (!this.settings.multiple && this.optionSelection.selected().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggleSelection(opt);
|
||||
|
||||
this.resetField();
|
||||
@ -375,12 +398,17 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!this.settings.multiple && this.optionSelection.selected().length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inputElem) {
|
||||
// hack: To prevent multiple typeaheads from being open at once, click document then trigger the focus
|
||||
document.querySelector('body')?.click();
|
||||
this.inputElem.nativeElement.focus();
|
||||
this.hasFocus = true;
|
||||
}
|
||||
|
||||
|
||||
this.openDropdown();
|
||||
}
|
||||
@ -415,4 +443,10 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||
|
||||
}
|
||||
|
||||
unlock(event: any) {
|
||||
this.locked = !this.locked;
|
||||
this.onUnlock.emit();
|
||||
this.lockedChange.emit(this.locked);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -13,4 +13,8 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
z-index: 1055 !important; // ngb v12 bug: https://github.com/ng-bootstrap/ng-bootstrap/issues/2686
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user