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:
Joseph Milazzo 2022-03-04 15:04:15 -06:00 committed by GitHub
parent 47a92a2e01
commit ba77954d5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 3605 additions and 723 deletions

View File

@ -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));

View File

@ -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()
{

View File

@ -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));
}
}

View File

@ -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));
}
}

View File

@ -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)

View File

@ -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

View File

@ -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>();
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}
}

View File

@ -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>

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

View File

@ -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");

View File

@ -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

View File

@ -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);
}

View File

@ -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; }
}
}

View File

@ -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()
{

View File

@ -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>();

View File

@ -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)));

View File

@ -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();
}

View File

@ -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>

View File

@ -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);

View File

@ -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);

View File

@ -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>

View File

@ -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"
}

View File

@ -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",

View File

@ -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>;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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) => {

View File

@ -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">

View File

@ -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({

View File

@ -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>&nbsp;
{{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>

View File

@ -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);
});
}
}

View File

@ -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>

View File

@ -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});
}
}

View File

@ -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>

View File

@ -2,3 +2,10 @@
max-height: 90vh; // 600px
overflow: auto;
}
.lock-active {
> .input-group-text {
background-color: var(--primary-color);
color: white;
}
}

View File

@ -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];
}
}
}

View File

@ -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,

View File

@ -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>&nbsp;
{{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>

View File

@ -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) {

View File

@ -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"

View File

@ -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 { }

View File

@ -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 {

View File

@ -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
*/

View File

@ -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>

View File

@ -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%;

View File

@ -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);
}
}

View File

@ -13,4 +13,8 @@
text-decoration: none;
}
}
}
.dropdown {
z-index: 1055 !important; // ngb v12 bug: https://github.com/ng-bootstrap/ng-bootstrap/issues/2686
}