mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
UX Overhaul Part 2 (#3112)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
0247bc5012
commit
3d8aa2ad24
@ -1,20 +1,40 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nager.ArticleNumber;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
public class ChapterController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public ChapterController(IUnitOfWork unitOfWork)
|
||||
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
|
||||
{
|
||||
@ -25,5 +45,234 @@ public class ChapterController : BaseApiController
|
||||
return Ok(chapter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a Chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult<bool>> DeleteChapter(int chapterId)
|
||||
{
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
if (chapter == null)
|
||||
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
|
||||
var vol = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!;
|
||||
_unitOfWork.ChapterRepository.Remove(chapter);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.ChapterRemoved, MessageFactory.ChapterRemovedEvent(chapter.Id, vol.SeriesId), false);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update chapter metadata
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> UpdateChapterMetadata(UpdateChapterDto dto)
|
||||
{
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.Id, ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags);
|
||||
if (chapter == null)
|
||||
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
|
||||
if (chapter.AgeRating != dto.AgeRating)
|
||||
{
|
||||
chapter.AgeRating = dto.AgeRating;
|
||||
}
|
||||
|
||||
|
||||
if (chapter.Summary != dto.Summary.Trim())
|
||||
{
|
||||
chapter.Summary = dto.Summary.Trim();
|
||||
}
|
||||
|
||||
if (chapter.Language != dto.Language)
|
||||
{
|
||||
chapter.Language = dto.Language ?? string.Empty;
|
||||
}
|
||||
|
||||
if (chapter.SortOrder.IsNot(dto.SortOrder))
|
||||
{
|
||||
chapter.SortOrder = dto.SortOrder; // TODO: Figure out validation
|
||||
}
|
||||
|
||||
if (chapter.TitleName != dto.TitleName)
|
||||
{
|
||||
chapter.TitleName = dto.TitleName;
|
||||
}
|
||||
|
||||
if (chapter.ReleaseDate != dto.ReleaseDate)
|
||||
{
|
||||
chapter.ReleaseDate = dto.ReleaseDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(dto.ISBN) && ArticleNumberHelper.IsValidIsbn10(dto.ISBN) ||
|
||||
ArticleNumberHelper.IsValidIsbn13(dto.ISBN))
|
||||
{
|
||||
chapter.ISBN = dto.ISBN;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(dto.WebLinks))
|
||||
{
|
||||
chapter.WebLinks = string.Empty;
|
||||
} else
|
||||
{
|
||||
chapter.WebLinks = string.Join(',', dto.WebLinks
|
||||
.Split(',')
|
||||
.Where(s => !string.IsNullOrEmpty(s))
|
||||
.Select(s => s.Trim())!
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#region Genres
|
||||
if (dto.Genres != null &&
|
||||
dto.Genres.Count != 0)
|
||||
{
|
||||
var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(dto.Genres.Select(t => Parser.Normalize(t.Title)))).ToList();
|
||||
chapter.Genres ??= new List<Genre>();
|
||||
GenreHelper.UpdateGenreList(dto.Genres, chapter, allGenres, genre =>
|
||||
{
|
||||
chapter.Genres.Add(genre);
|
||||
}, () => chapter.GenresLocked = true);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Tags
|
||||
if (dto.Tags is {Count: > 0})
|
||||
{
|
||||
var allTags = (await _unitOfWork.TagRepository
|
||||
.GetAllTagsByNameAsync(dto.Tags.Select(t => Parser.Normalize(t.Title))))
|
||||
.ToList();
|
||||
chapter.Tags ??= new List<Tag>();
|
||||
TagHelper.UpdateTagList(dto.Tags, chapter, allTags, tag =>
|
||||
{
|
||||
chapter.Tags.Add(tag);
|
||||
}, () => chapter.TagsLocked = true);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region People
|
||||
if (PersonHelper.HasAnyPeople(dto))
|
||||
{
|
||||
void HandleAddPerson(Person person)
|
||||
{
|
||||
PersonHelper.AddPersonIfNotExists(chapter.People, person);
|
||||
}
|
||||
|
||||
chapter.People ??= new List<Person>();
|
||||
var allWriters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Writer,
|
||||
dto.Writers.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Writer, dto.Writers, chapter, allWriters.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.WriterLocked = true);
|
||||
|
||||
var allCharacters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Character,
|
||||
dto.Characters.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Character, dto.Characters, chapter, allCharacters.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.CharacterLocked = true);
|
||||
|
||||
var allColorists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Colorist,
|
||||
dto.Colorists.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Colorist, dto.Colorists, chapter, allColorists.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.ColoristLocked = true);
|
||||
|
||||
var allEditors = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Editor,
|
||||
dto.Editors.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Editor, dto.Editors, chapter, allEditors.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.EditorLocked = true);
|
||||
|
||||
var allInkers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Inker,
|
||||
dto.Inkers.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Inker, dto.Inkers, chapter, allInkers.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.InkerLocked = true);
|
||||
|
||||
var allLetterers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Letterer,
|
||||
dto.Letterers.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Letterer, dto.Letterers, chapter, allLetterers.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.LettererLocked = true);
|
||||
|
||||
var allPencillers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Penciller,
|
||||
dto.Pencillers.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Penciller, dto.Pencillers, chapter, allPencillers.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.PencillerLocked = true);
|
||||
|
||||
var allPublishers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Publisher,
|
||||
dto.Publishers.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Publisher, dto.Publishers, chapter, allPublishers.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.PublisherLocked = true);
|
||||
|
||||
var allImprints = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Imprint,
|
||||
dto.Imprints.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Imprint, dto.Imprints, chapter, allImprints.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.ImprintLocked = true);
|
||||
|
||||
var allTeams = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Team,
|
||||
dto.Imprints.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Team, dto.Teams, chapter, allTeams.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.TeamLocked = true);
|
||||
|
||||
var allLocations = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Location,
|
||||
dto.Imprints.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Location, dto.Locations, chapter, allLocations.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.LocationLocked = true);
|
||||
|
||||
var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator,
|
||||
dto.Translators.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.Translator, dto.Translators, chapter, allTranslators.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.TranslatorLocked = true);
|
||||
|
||||
var allCoverArtists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.CoverArtist,
|
||||
dto.CoverArtists.Select(p => Parser.Normalize(p.Name)));
|
||||
PersonHelper.UpdatePeopleList(PersonRole.CoverArtist, dto.CoverArtists, chapter, allCoverArtists.AsReadOnly(),
|
||||
HandleAddPerson, () => chapter.CoverArtistLocked = true);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Locks
|
||||
chapter.AgeRatingLocked = dto.AgeRatingLocked;
|
||||
chapter.LanguageLocked = dto.LanguageLocked;
|
||||
chapter.TitleNameLocked = dto.TitleNameLocked;
|
||||
chapter.SortOrderLocked = dto.SortOrderLocked;
|
||||
chapter.GenresLocked = dto.GenresLocked;
|
||||
chapter.TagsLocked = dto.TagsLocked;
|
||||
chapter.CharacterLocked = dto.CharacterLocked;
|
||||
chapter.ColoristLocked = dto.ColoristLocked;
|
||||
chapter.EditorLocked = dto.EditorLocked;
|
||||
chapter.InkerLocked = dto.InkerLocked;
|
||||
chapter.ImprintLocked = dto.ImprintLocked;
|
||||
chapter.LettererLocked = dto.LettererLocked;
|
||||
chapter.PencillerLocked = dto.PencillerLocked;
|
||||
chapter.PublisherLocked = dto.PublisherLocked;
|
||||
chapter.TranslatorLocked = dto.TranslatorLocked;
|
||||
chapter.CoverArtistLocked = dto.CoverArtistLocked;
|
||||
chapter.WriterLocked = dto.WriterLocked;
|
||||
chapter.SummaryLocked = dto.SummaryLocked;
|
||||
chapter.ISBNLocked = dto.ISBNLocked;
|
||||
chapter.ReleaseDateLocked = dto.ReleaseDateLocked;
|
||||
#endregion
|
||||
|
||||
|
||||
if (!_unitOfWork.HasChanges())
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// TODO: Emit a ChapterMetadataUpdate out
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ public class ReadingListController : BaseApiController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all Reading Lists the user has access to that have a series within it.
|
||||
/// Returns all Reading Lists the user has access to that the given series within it.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
@ -74,6 +74,18 @@ public class ReadingListController : BaseApiController
|
||||
seriesId, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all Reading Lists the user has access to that has the given chapter within it.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("lists-for-chapter")]
|
||||
public async Task<ActionResult<IEnumerable<ReadingListDto>>> GetListsForChapter(int chapterId)
|
||||
{
|
||||
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtosForChapterAndUserAsync(User.GetUserId(),
|
||||
chapterId, true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
|
||||
/// </summary>
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
@ -92,28 +93,36 @@ public class UploadController : BaseApiController
|
||||
{
|
||||
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
|
||||
// See if we can do this all in memory without touching underlying system
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
|
||||
|
||||
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "series-doesnt-exist"));
|
||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
|
||||
|
||||
var filePath = string.Empty;
|
||||
var lockState = false;
|
||||
if (!string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}");
|
||||
lockState = uploadFileDto.LockCover;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
series.CoverImage = filePath;
|
||||
series.CoverImageLocked = true;
|
||||
series.CoverImageLocked = lockState;
|
||||
_imageService.UpdateColorScape(series);
|
||||
_unitOfWork.SeriesRepository.Update(series);
|
||||
}
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
// Refresh covers
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
|
||||
}
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series), false);
|
||||
await _unitOfWork.CommitAsync();
|
||||
@ -142,25 +151,24 @@ public class UploadController : BaseApiController
|
||||
{
|
||||
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
|
||||
// See if we can do this all in memory without touching underlying system
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id);
|
||||
if (tag == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "collection-doesnt-exist"));
|
||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
var filePath = string.Empty;
|
||||
var lockState = false;
|
||||
if (!string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
tag.CoverImage = filePath;
|
||||
tag.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(tag);
|
||||
_unitOfWork.CollectionTagRepository.Update(tag);
|
||||
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}");
|
||||
lockState = uploadFileDto.LockCover;
|
||||
}
|
||||
|
||||
tag.CoverImage = filePath;
|
||||
tag.CoverImageLocked = lockState;
|
||||
_imageService.UpdateColorScape(tag);
|
||||
_unitOfWork.CollectionTagRepository.Update(tag);
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
@ -189,30 +197,31 @@ public class UploadController : BaseApiController
|
||||
[HttpPost("reading-list")]
|
||||
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
|
||||
{
|
||||
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
|
||||
// Check if Url is non-empty, request the image and place in temp, then ask image service to handle it.
|
||||
// See if we can do this all in memory without touching underlying system
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
|
||||
}
|
||||
|
||||
if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null)
|
||||
if (await _readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null)
|
||||
return Unauthorized(await _localizationService.Translate(User.GetUserId(), "access-denied"));
|
||||
|
||||
try
|
||||
{
|
||||
var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id);
|
||||
if (readingList == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reading-list-doesnt-exist"));
|
||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
|
||||
var filePath = string.Empty;
|
||||
var lockState = false;
|
||||
if (!string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
readingList.CoverImage = filePath;
|
||||
readingList.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(readingList);
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
|
||||
lockState = uploadFileDto.LockCover;
|
||||
}
|
||||
|
||||
|
||||
readingList.CoverImage = filePath;
|
||||
readingList.CoverImageLocked = lockState;
|
||||
_imageService.UpdateColorScape(readingList);
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
@ -253,33 +262,42 @@ public class UploadController : BaseApiController
|
||||
{
|
||||
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
|
||||
// See if we can do this all in memory without touching underlying system
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
|
||||
if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
var filePath = string.Empty;
|
||||
var lockState = false;
|
||||
if (!string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
chapter.CoverImage = filePath;
|
||||
chapter.CoverImageLocked = true;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId);
|
||||
if (volume != null)
|
||||
{
|
||||
volume.CoverImage = chapter.CoverImage;
|
||||
_unitOfWork.VolumeRepository.Update(volume);
|
||||
}
|
||||
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}");
|
||||
lockState = uploadFileDto.LockCover;
|
||||
}
|
||||
|
||||
chapter.CoverImage = filePath;
|
||||
chapter.CoverImageLocked = lockState;
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId);
|
||||
if (volume != null)
|
||||
{
|
||||
volume.CoverImage = chapter.CoverImage;
|
||||
volume.CoverImageLocked = lockState;
|
||||
_unitOfWork.VolumeRepository.Update(volume);
|
||||
}
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Refresh covers
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId))!;
|
||||
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
|
||||
}
|
||||
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
@ -310,11 +328,6 @@ public class UploadController : BaseApiController
|
||||
{
|
||||
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
|
||||
// See if we can do this all in memory without touching underlying system
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(uploadFileDto.Id, VolumeIncludes.Chapters);
|
||||
@ -323,24 +336,37 @@ public class UploadController : BaseApiController
|
||||
// Find the first chapter of the volume
|
||||
var chapter = volume.Chapters[0];
|
||||
|
||||
var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(chapter.Id, uploadFileDto.Id)}");
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
var filePath = string.Empty;
|
||||
var lockState = false;
|
||||
if (!string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
chapter.CoverImage = filePath;
|
||||
chapter.CoverImageLocked = true;
|
||||
_imageService.UpdateColorScape(chapter);
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
|
||||
volume.CoverImage = chapter.CoverImage;
|
||||
_imageService.UpdateColorScape(volume);
|
||||
_unitOfWork.VolumeRepository.Update(volume);
|
||||
filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(chapter.Id, uploadFileDto.Id)}");
|
||||
lockState = uploadFileDto.LockCover;
|
||||
}
|
||||
|
||||
|
||||
chapter.CoverImage = filePath;
|
||||
chapter.CoverImageLocked = lockState;
|
||||
_imageService.UpdateColorScape(chapter);
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
|
||||
volume.CoverImage = chapter.CoverImage;
|
||||
volume.CoverImageLocked = lockState;
|
||||
_imageService.UpdateColorScape(volume);
|
||||
_unitOfWork.VolumeRepository.Update(volume);
|
||||
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
// Refresh covers
|
||||
if (string.IsNullOrEmpty(uploadFileDto.Url))
|
||||
{
|
||||
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!;
|
||||
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
|
||||
}
|
||||
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
MessageFactory.CoverUpdateEvent(chapter.VolumeId, MessageFactoryEntityTypes.Volume), false);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
|
||||
@ -426,6 +452,7 @@ public class UploadController : BaseApiController
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("reset-chapter-lock")]
|
||||
[Obsolete("Use LockCover in UploadFileDto")]
|
||||
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
|
||||
{
|
||||
try
|
||||
@ -461,4 +488,6 @@ public class UploadController : BaseApiController
|
||||
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock"));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
54
API/Controllers/VolumeController.cs
Normal file
54
API/Controllers/VolumeController.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using API.SignalR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
public class VolumeController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public VolumeController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<VolumeDto>> GetVolume(int volumeId)
|
||||
{
|
||||
var volume =
|
||||
await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, User.GetUserId());
|
||||
|
||||
return Ok(volume);
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult<bool>> DeleteVolume(int volumeId)
|
||||
{
|
||||
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId,
|
||||
VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags);
|
||||
if (volume == null)
|
||||
return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
|
||||
|
||||
_unitOfWork.VolumeRepository.Remove(volume);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.VolumeRemoved, MessageFactory.VolumeRemovedEvent(volume.Id, volume.SeriesId), false);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
@ -158,6 +158,33 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
/// </summary>
|
||||
public int TotalCount { 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 ImprintLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool TeamLocked { get; set; }
|
||||
public bool LocationLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
public bool ReleaseYearLocked { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
public string CoverImage { get; set; }
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Metadata;
|
||||
@ -7,6 +8,7 @@ namespace API.DTOs.Metadata;
|
||||
/// <summary>
|
||||
/// Exclusively metadata about a given chapter
|
||||
/// </summary>
|
||||
[Obsolete("Will not be maintained as of v0.8.1")]
|
||||
public class ChapterMetadataDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
94
API/DTOs/UpdateChapterDto.cs
Normal file
94
API/DTOs/UpdateChapterDto.cs
Normal file
@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Metadata;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
public class UpdateChapterDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Genres for the Chapter
|
||||
/// </summary>
|
||||
public ICollection<GenreTagDto> Genres { get; set; } = new List<GenreTagDto>();
|
||||
/// <summary>
|
||||
/// Collection of all Tags from underlying chapters for a Chapter
|
||||
/// </summary>
|
||||
public ICollection<TagDto> Tags { get; set; } = new List<TagDto>();
|
||||
|
||||
public ICollection<PersonDto> Writers { get; set; } = new List<PersonDto>();
|
||||
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> Imprints { 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<PersonDto> Teams { get; set; } = new List<PersonDto>();
|
||||
public ICollection<PersonDto> Locations { get; set; } = new List<PersonDto>();
|
||||
|
||||
/// <summary>
|
||||
/// Highest Age Rating from all Chapters
|
||||
/// </summary>
|
||||
public AgeRating AgeRating { get; set; } = AgeRating.Unknown;
|
||||
/// <summary>
|
||||
/// Language of the content (BCP-47 code)
|
||||
/// </summary>
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Locked by user so metadata updates from scan loop will not override AgeRating
|
||||
/// </summary>
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
public bool TitleNameLocked { 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 ImprintLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool TeamLocked { get; set; }
|
||||
public bool LocationLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
public bool LanguageLocked { get; set; }
|
||||
public bool SummaryLocked { get; set; }
|
||||
public bool ISBNLocked { get; set; }
|
||||
public bool ReleaseDateLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.
|
||||
/// </summary>
|
||||
public float SortOrder { get; set; }
|
||||
/// <summary>
|
||||
/// Can the sort order be updated on scan or is it locked from UI
|
||||
/// </summary>
|
||||
public bool SortOrderLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated link of urls to external services that have some relation to the Chapter
|
||||
/// </summary>
|
||||
public string WebLinks { get; set; } = string.Empty;
|
||||
public string ISBN { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Date which chapter was released
|
||||
/// </summary>
|
||||
public DateTime ReleaseDate { get; set; }
|
||||
/// <summary>
|
||||
/// Chapter title
|
||||
/// </summary>
|
||||
/// <remarks>This should not be confused with Title which is used for special filenames.</remarks>
|
||||
public string TitleName { get; set; } = string.Empty;
|
||||
}
|
@ -10,4 +10,9 @@ public class UploadFileDto
|
||||
/// Base Url encoding of the file to upload from (can be null)
|
||||
/// </summary>
|
||||
public required string Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lock the cover or not
|
||||
/// </summary>
|
||||
public bool LockCover { get; set; } = true;
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
public int MaxHoursToRead { get; set; }
|
||||
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
|
||||
public int AvgHoursToRead { get; set; }
|
||||
public long WordCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Is this a loose leaf volume
|
||||
@ -64,6 +65,7 @@ public class VolumeDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
}
|
||||
|
||||
public string CoverImage { get; set; }
|
||||
private bool CoverImageLocked { get; set; }
|
||||
public string PrimaryColor { get; set; }
|
||||
public string SecondaryColor { get; set; }
|
||||
|
||||
|
3142
API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs
generated
Normal file
3142
API/Data/Migrations/20240811154857_ChapterMetadataLocks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
249
API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs
Normal file
249
API/Data/Migrations/20240811154857_ChapterMetadataLocks.cs
Normal file
@ -0,0 +1,249 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ChapterMetadataLocks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AgeRatingLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CharacterLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ColoristLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CoverArtistLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EditorLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "GenresLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ISBNLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ImprintLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "InkerLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LanguageLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LettererLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LocationLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PencillerLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PublisherLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ReleaseDateLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "SummaryLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TagsLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TeamLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TitleNameLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TranslatorLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WriterLocked",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AgeRatingLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ColoristLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CoverArtistLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EditorLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "GenresLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ISBNLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ImprintLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InkerLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LanguageLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LettererLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LocationLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PencillerLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PublisherLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReleaseDateLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SummaryLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TagsLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TeamLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TitleNameLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TranslatorLocked",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WriterLocked",
|
||||
table: "Chapter");
|
||||
}
|
||||
}
|
||||
}
|
3145
API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs
generated
Normal file
3145
API/Data/Migrations/20240813194728_VolumeCoverLocked.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20240813194728_VolumeCoverLocked.cs
Normal file
29
API/Data/Migrations/20240813194728_VolumeCoverLocked.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class VolumeCoverLocked : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CoverImageLocked",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CoverImageLocked",
|
||||
table: "Volume");
|
||||
}
|
||||
}
|
||||
}
|
@ -719,6 +719,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AgeRating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AgeRatingLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AlternateCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -731,9 +734,18 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("AvgHoursToRead")
|
||||
.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<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -746,23 +758,47 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EditorLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("GenresLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ISBN")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("ISBNLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ImprintLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("InkerLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpecial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LanguageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModifiedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LettererLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("LocationLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MaxHoursToRead")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -781,15 +817,24 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("Pages")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("PencillerLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PublisherLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Range")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ReleaseDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ReleaseDateLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -811,15 +856,30 @@ 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>("TeamLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TitleName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TitleNameLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TotalCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("TranslatorLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VolumeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -831,6 +891,9 @@ namespace API.Data.Migrations
|
||||
b.Property<long>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WriterLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VolumeId");
|
||||
@ -1983,6 +2046,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("CoverImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("CoverImageLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -22,12 +22,15 @@ public enum ChapterIncludes
|
||||
None = 1,
|
||||
Volumes = 2,
|
||||
Files = 4,
|
||||
People = 8
|
||||
People = 8,
|
||||
Genres = 16,
|
||||
Tags = 32
|
||||
}
|
||||
|
||||
public interface IChapterRepository
|
||||
{
|
||||
void Update(Chapter chapter);
|
||||
void Remove(Chapter chapter);
|
||||
Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None);
|
||||
Task<IChapterInfoDto?> GetChapterInfoDtoAsync(int chapterId);
|
||||
Task<int> GetChapterTotalPagesAsync(int chapterId);
|
||||
@ -60,6 +63,11 @@ public class ChapterRepository : IChapterRepository
|
||||
_context.Entry(chapter).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Remove(Chapter chapter)
|
||||
{
|
||||
_context.Chapter.Remove(chapter);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Chapter>> GetChaptersByIdsAsync(IList<int> chapterIds, ChapterIncludes includes = ChapterIncludes.None)
|
||||
{
|
||||
return await _context.Chapter
|
||||
|
@ -36,6 +36,8 @@ public interface IReadingListRepository
|
||||
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
|
||||
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
|
||||
bool includePromoted);
|
||||
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId,
|
||||
bool includePromoted);
|
||||
void Remove(ReadingListItem item);
|
||||
void Add(ReadingList list);
|
||||
void BulkRemove(IEnumerable<ReadingListItem> items);
|
||||
@ -166,6 +168,8 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public void Remove(ReadingListItem item)
|
||||
{
|
||||
_context.ReadingListItem.Remove(item);
|
||||
@ -204,6 +208,19 @@ public class ReadingListRepository : IReadingListRepository
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ReadingListDto>> GetReadingListDtosForChapterAndUserAsync(int userId, int chapterId, bool includePromoted)
|
||||
{
|
||||
var query = _context.ReadingList
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.Where(l => l.Items.Any(i => i.ChapterId == chapterId))
|
||||
.AsSplitQuery()
|
||||
.OrderBy(l => l.Title)
|
||||
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ReadingList?> GetReadingListByIdAsync(int readingListId, ReadingListIncludes includes = ReadingListIncludes.None)
|
||||
{
|
||||
return await _context.ReadingList
|
||||
|
@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Extensions;
|
||||
@ -125,6 +124,32 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public string WebLinks { get; set; } = string.Empty;
|
||||
public string ISBN { get; set; } = string.Empty;
|
||||
|
||||
#region Locks
|
||||
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
public bool TitleNameLocked { 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 ImprintLocked { get; set; }
|
||||
public bool LettererLocked { get; set; }
|
||||
public bool PencillerLocked { get; set; }
|
||||
public bool PublisherLocked { get; set; }
|
||||
public bool TranslatorLocked { get; set; }
|
||||
public bool TeamLocked { get; set; }
|
||||
public bool LocationLocked { get; set; }
|
||||
public bool CoverArtistLocked { get; set; }
|
||||
public bool LanguageLocked { get; set; }
|
||||
public bool SummaryLocked { get; set; }
|
||||
public bool ISBNLocked { get; set; }
|
||||
public bool ReleaseDateLocked { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// All people attached at a Chapter level. Usually Comics will have different people per issue.
|
||||
/// </summary>
|
||||
|
@ -32,13 +32,13 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
/// The maximum number in the Name field (same as Minimum if Name isn't a range)
|
||||
/// </summary>
|
||||
public required float MaxNumber { get; set; }
|
||||
public IList<Chapter> Chapters { get; set; } = null!;
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
|
||||
public string? CoverImage { get; set; }
|
||||
public bool CoverImageLocked { get; set; }
|
||||
public string PrimaryColor { get; set; }
|
||||
public string SecondaryColor { get; set; }
|
||||
|
||||
@ -57,6 +57,7 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
|
||||
|
||||
// Relationships
|
||||
public IList<Chapter> Chapters { get; set; } = null!;
|
||||
public Series Series { get; set; } = null!;
|
||||
public int SeriesId { get; set; }
|
||||
|
||||
|
@ -59,6 +59,18 @@ public static class IncludesExtensions
|
||||
.Include(c => c.People);
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ChapterIncludes.Genres))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(c => c.Genres);
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ChapterIncludes.Tags))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(c => c.Tags);
|
||||
}
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
|
@ -108,4 +108,49 @@ public static class GenreHelper
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateGenreList(ICollection<GenreTagDto>? tags, Chapter chapter,
|
||||
IReadOnlyCollection<Genre> allTags, Action<Genre> handleAdd, Action onModified)
|
||||
{
|
||||
// TODO: Write some unit tests
|
||||
if (tags == null) return;
|
||||
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 = chapter.Genres.ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (tags.SingleOrDefault(t => t.Title.ToNormalized().Equals(existing.NormalizedTitle)) == null)
|
||||
{
|
||||
// Remove tag
|
||||
chapter.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 normalizedTitle = tagTitle.ToNormalized();
|
||||
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (chapter.Genres.All(t => !t.NormalizedTitle.Equals(normalizedTitle)))
|
||||
{
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(new GenreBuilder(tagTitle).Build());
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModified)
|
||||
{
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -165,18 +165,76 @@ public static class PersonHelper
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasAnyPeople(SeriesMetadataDto? seriesMetadata)
|
||||
public static void UpdatePeopleList(PersonRole role, ICollection<PersonDto>? people, Chapter chapter, IReadOnlyCollection<Person> allPeople,
|
||||
Action<Person> handleAdd, Action onModified)
|
||||
{
|
||||
if (seriesMetadata == null) return false;
|
||||
return seriesMetadata.Writers.Any() ||
|
||||
seriesMetadata.CoverArtists.Any() ||
|
||||
seriesMetadata.Publishers.Any() ||
|
||||
seriesMetadata.Characters.Any() ||
|
||||
seriesMetadata.Pencillers.Any() ||
|
||||
seriesMetadata.Inkers.Any() ||
|
||||
seriesMetadata.Colorists.Any() ||
|
||||
seriesMetadata.Letterers.Any() ||
|
||||
seriesMetadata.Editors.Any() ||
|
||||
seriesMetadata.Translators.Any();
|
||||
if (people == null) return;
|
||||
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 = chapter.People.Where(p => p.Role == role).ToList();
|
||||
foreach (var existing in existingTags)
|
||||
{
|
||||
if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role
|
||||
{
|
||||
// Remove tag
|
||||
chapter.People.Remove(existing);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// At this point, all tags that aren't in dto have been removed.
|
||||
foreach (var tag in people)
|
||||
{
|
||||
var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role);
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (chapter.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name)))
|
||||
{
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(new PersonBuilder(tag.Name, role).Build());
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModified)
|
||||
{
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasAnyPeople(SeriesMetadataDto? dto)
|
||||
{
|
||||
if (dto == null) return false;
|
||||
return dto.Writers.Count != 0 ||
|
||||
dto.CoverArtists.Count != 0 ||
|
||||
dto.Publishers.Count != 0 ||
|
||||
dto.Characters.Count != 0 ||
|
||||
dto.Pencillers.Count != 0 ||
|
||||
dto.Inkers.Count != 0 ||
|
||||
dto.Colorists.Count != 0 ||
|
||||
dto.Letterers.Count != 0 ||
|
||||
dto.Editors.Count != 0 ||
|
||||
dto.Translators.Count != 0;
|
||||
}
|
||||
|
||||
public static bool HasAnyPeople(UpdateChapterDto? dto)
|
||||
{
|
||||
if (dto == null) return false;
|
||||
return dto.Writers.Count != 0 ||
|
||||
dto.CoverArtists.Count != 0 ||
|
||||
dto.Publishers.Count != 0 ||
|
||||
dto.Characters.Count != 0 ||
|
||||
dto.Pencillers.Count != 0 ||
|
||||
dto.Inkers.Count != 0 ||
|
||||
dto.Colorists.Count != 0 ||
|
||||
dto.Letterers.Count != 0 ||
|
||||
dto.Editors.Count != 0 ||
|
||||
dto.Translators.Count != 0;
|
||||
}
|
||||
}
|
||||
|
@ -151,6 +151,48 @@ public static class TagHelper
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
|
||||
public static void UpdateTagList(ICollection<TagDto>? tags, Chapter chapter, IReadOnlyCollection<Tag> allTags, Action<Tag> handleAdd, Action onModified)
|
||||
{
|
||||
if (tags == null) return;
|
||||
|
||||
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 = chapter.Tags.ToList();
|
||||
foreach (var existing in existingTags.Where(existing => tags.SingleOrDefault(t => t.Id == existing.Id) == null))
|
||||
{
|
||||
// Remove tag
|
||||
chapter.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 normalizedTitle = tagTitle.ToNormalized();
|
||||
var existingTag = allTags.SingleOrDefault(t => t.NormalizedTitle.Equals(normalizedTitle));
|
||||
if (existingTag != null)
|
||||
{
|
||||
if (chapter.Tags.All(t => t.NormalizedTitle != normalizedTitle))
|
||||
{
|
||||
|
||||
handleAdd(existingTag);
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add new tag
|
||||
handleAdd(new TagBuilder(tagTitle).Build());
|
||||
isModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isModified)
|
||||
{
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#nullable disable
|
||||
|
@ -580,8 +580,8 @@ public class ImageService : IImageService
|
||||
{
|
||||
return colors.OrderByDescending(c =>
|
||||
{
|
||||
float max = Math.Max(c.X, Math.Max(c.Y, c.Z));
|
||||
float min = Math.Min(c.X, Math.Min(c.Y, c.Z));
|
||||
var max = Math.Max(c.X, Math.Max(c.Y, c.Z));
|
||||
var min = Math.Min(c.X, Math.Min(c.Y, c.Z));
|
||||
return (max - min) / max;
|
||||
}).ToList();
|
||||
}
|
||||
|
@ -75,8 +75,10 @@ public class MetadataService : IMetadataService
|
||||
/// <param name="chapter"></param>
|
||||
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
|
||||
/// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
|
||||
private Task<bool> UpdateChapterCoverImage(Chapter chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize)
|
||||
private Task<bool> UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize)
|
||||
{
|
||||
if (chapter == null) return Task.FromResult(false);
|
||||
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
if (firstFile == null) return Task.FromResult(false);
|
||||
|
||||
@ -133,7 +135,9 @@ public class MetadataService : IMetadataService
|
||||
private Task<bool> UpdateVolumeCoverImage(Volume? volume, bool forceUpdate)
|
||||
{
|
||||
// We need to check if Volume coverImage matches first chapters if forceUpdate is false
|
||||
if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(
|
||||
if (volume == null) return Task.FromResult(false);
|
||||
|
||||
if (!_cacheHelper.ShouldUpdateCoverImage(
|
||||
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage),
|
||||
null, volume.Created, forceUpdate))
|
||||
{
|
||||
@ -146,18 +150,20 @@ public class MetadataService : IMetadataService
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// For cover selection, chapters need to try for issue 1 first, then fallback to first sort order
|
||||
volume.Chapters ??= new List<Chapter>();
|
||||
|
||||
var firstChapter = volume.Chapters.FirstOrDefault(x => x.MinNumber.Is(1f));
|
||||
if (firstChapter == null)
|
||||
if (!volume.CoverImageLocked)
|
||||
{
|
||||
firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default);
|
||||
if (firstChapter == null) return Task.FromResult(false);
|
||||
// For cover selection, chapters need to try for issue 1 first, then fallback to first sort order
|
||||
volume.Chapters ??= new List<Chapter>();
|
||||
|
||||
var firstChapter = volume.Chapters.FirstOrDefault(x => x.MinNumber.Is(1f));
|
||||
if (firstChapter == null)
|
||||
{
|
||||
firstChapter = volume.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default);
|
||||
if (firstChapter == null) return Task.FromResult(false);
|
||||
}
|
||||
|
||||
volume.CoverImage = firstChapter.CoverImage;
|
||||
}
|
||||
|
||||
|
||||
volume.CoverImage = firstChapter.CoverImage;
|
||||
_imageService.UpdateColorScape(volume);
|
||||
|
||||
_updateEvents.Add(MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Volume));
|
||||
|
@ -129,62 +129,81 @@ public class ParseScannedFiles
|
||||
|
||||
var result = new List<ScanResult>();
|
||||
|
||||
// Not to self: this whole thing can be parallelized because we don't deal with any DB or global state
|
||||
if (scanDirectoryByDirectory)
|
||||
{
|
||||
var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath);
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated));
|
||||
|
||||
|
||||
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck))
|
||||
{
|
||||
if (result.Exists(r => r.Folder == directory))
|
||||
{
|
||||
_logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added", directory);
|
||||
continue;
|
||||
}
|
||||
result.Add(CreateScanResult(directory, folderPath, false, ArraySegment<string>.Empty));
|
||||
}
|
||||
else if (!forceCheck && seriesPaths.TryGetValue(directory, out var series) && series.Count > 1 && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath)))
|
||||
{
|
||||
// If there are multiple series inside this path, let's check each of them to see which was modified and only scan those
|
||||
// This is very helpful for ComicVine libraries by Publisher
|
||||
_logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory);
|
||||
foreach (var seriesModified in series)
|
||||
{
|
||||
var hasFolderChangedSinceLastScan = seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) <
|
||||
_directoryService
|
||||
.GetLastWriteTime(seriesModified.LowestFolderPath!)
|
||||
.Truncate(TimeSpan.TicksPerSecond);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated));
|
||||
|
||||
if (!hasFolderChangedSinceLastScan)
|
||||
{
|
||||
_logger.LogTrace("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath);
|
||||
result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment<string>.Empty));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogTrace("[ProcessFiles] {Directory} subfolder {Folder} changed for Series {SeriesName}", directory, seriesModified.LowestFolderPath, seriesModified.SeriesName);
|
||||
result.Add(CreateScanResult(directory, folderPath, true,
|
||||
_directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher)));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(CreateScanResult(directory, folderPath, true,
|
||||
_directoryService.ScanFiles(directory, fileExtensions, matcher)));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return await ScanDirectories(folderPath, seriesPaths, library, forceCheck, matcher, result, fileExtensions);
|
||||
}
|
||||
|
||||
return await ScanSingleDirectory(folderPath, seriesPaths, library, forceCheck, result, fileExtensions, matcher);
|
||||
}
|
||||
|
||||
private async Task<IList<ScanResult>> ScanDirectories(string folderPath, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck,
|
||||
GlobMatcher matcher, List<ScanResult> result, string fileExtensions)
|
||||
{
|
||||
var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath);
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated));
|
||||
|
||||
|
||||
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck))
|
||||
{
|
||||
if (result.Exists(r => r.Folder == directory))
|
||||
{
|
||||
_logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added", directory);
|
||||
continue;
|
||||
}
|
||||
_logger.LogDebug("[ProcessFiles] Skipping {Directory} as it hasn't changed since last scan", directory);
|
||||
result.Add(CreateScanResult(directory, folderPath, false, ArraySegment<string>.Empty));
|
||||
}
|
||||
else if (!forceCheck && seriesPaths.TryGetValue(directory, out var series)
|
||||
&& series.Count > 1 && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath)))
|
||||
{
|
||||
// If there are multiple series inside this path, let's check each of them to see which was modified and only scan those
|
||||
// This is very helpful for ComicVine libraries by Publisher
|
||||
|
||||
// TODO: BUG: We might miss new folders this way. Likely need to get all folder names and see if there are any that aren't in known series list
|
||||
|
||||
_logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory);
|
||||
foreach (var seriesModified in series)
|
||||
{
|
||||
var hasFolderChangedSinceLastScan = seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) <
|
||||
_directoryService
|
||||
.GetLastWriteTime(seriesModified.LowestFolderPath!)
|
||||
.Truncate(TimeSpan.TicksPerSecond);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated));
|
||||
|
||||
if (!hasFolderChangedSinceLastScan)
|
||||
{
|
||||
_logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath);
|
||||
result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment<string>.Empty));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} changed for Series {SeriesName}", directory, seriesModified.LowestFolderPath, seriesModified.SeriesName);
|
||||
result.Add(CreateScanResult(directory, folderPath, true,
|
||||
_directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher)));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("[ProcessFiles] Performing file scan on {Directory}", directory);
|
||||
var files = _directoryService.ScanFiles(directory, fileExtensions, matcher);
|
||||
result.Add(CreateScanResult(directory, folderPath, true, files));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<IList<ScanResult>> ScanSingleDirectory(string folderPath, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck, List<ScanResult> result,
|
||||
string fileExtensions, GlobMatcher matcher)
|
||||
{
|
||||
var normalizedPath = Parser.Parser.NormalizePath(folderPath);
|
||||
var libraryRoot =
|
||||
library.Folders.FirstOrDefault(f =>
|
||||
@ -204,7 +223,6 @@ public class ParseScannedFiles
|
||||
_directoryService.ScanFiles(folderPath, fileExtensions, matcher)));
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
@ -760,12 +760,14 @@ public class ProcessSeries : IProcessSeries
|
||||
chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters).ToString(CultureInfo.InvariantCulture);
|
||||
chapter.MinNumber = Parser.Parser.MinNumberFromRange(info.Chapters);
|
||||
chapter.MaxNumber = Parser.Parser.MaxNumberFromRange(info.Chapters);
|
||||
chapter.Range = chapter.GetNumberTitle();
|
||||
|
||||
if (!chapter.SortOrderLocked)
|
||||
{
|
||||
chapter.SortOrder = info.IssueOrder;
|
||||
}
|
||||
chapter.Range = chapter.GetNumberTitle();
|
||||
if (float.TryParse(chapter.Title, out var _))
|
||||
|
||||
if (float.TryParse(chapter.Title, out _))
|
||||
{
|
||||
// If we have float based chapters, first scan can have the chapter formatted as Chapter 0.2 - .2 as the title is wrong.
|
||||
chapter.Title = chapter.GetNumberTitle();
|
||||
@ -832,19 +834,22 @@ public class ProcessSeries : IProcessSeries
|
||||
|
||||
_logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath);
|
||||
|
||||
chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating);
|
||||
if (!chapter.AgeRatingLocked)
|
||||
{
|
||||
chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Title))
|
||||
if (!chapter.TitleNameLocked && !string.IsNullOrEmpty(comicInfo.Title))
|
||||
{
|
||||
chapter.TitleName = comicInfo.Title.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Summary))
|
||||
if (!chapter.SummaryLocked && !string.IsNullOrEmpty(comicInfo.Summary))
|
||||
{
|
||||
chapter.Summary = comicInfo.Summary;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.LanguageISO))
|
||||
if (!chapter.LanguageLocked && !string.IsNullOrEmpty(comicInfo.LanguageISO))
|
||||
{
|
||||
chapter.Language = comicInfo.LanguageISO;
|
||||
}
|
||||
@ -888,7 +893,7 @@ public class ProcessSeries : IProcessSeries
|
||||
// For each weblink, try to parse out some MetadataIds and store in the Chapter directly for matching (CBL)
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Isbn))
|
||||
if (!chapter.ISBNLocked && !string.IsNullOrEmpty(comicInfo.Isbn))
|
||||
{
|
||||
chapter.ISBN = comicInfo.Isbn;
|
||||
}
|
||||
@ -902,84 +907,129 @@ public class ProcessSeries : IProcessSeries
|
||||
chapter.Count = comicInfo.CalculatedCount();
|
||||
|
||||
|
||||
if (comicInfo.Year > 0)
|
||||
if (!chapter.ReleaseDateLocked && comicInfo.Year > 0)
|
||||
{
|
||||
var day = Math.Max(comicInfo.Day, 1);
|
||||
var month = Math.Max(comicInfo.Month, 1);
|
||||
chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day);
|
||||
}
|
||||
|
||||
var people = TagHelper.GetTagValues(comicInfo.Colorist);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist);
|
||||
await UpdatePeople(chapter, people, PersonRole.Colorist);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Characters);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character);
|
||||
await UpdatePeople(chapter, people, PersonRole.Character);
|
||||
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Translator);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator);
|
||||
await UpdatePeople(chapter, people, PersonRole.Translator);
|
||||
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Writer);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer);
|
||||
await UpdatePeople(chapter, people, PersonRole.Writer);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Editor);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor);
|
||||
await UpdatePeople(chapter, people, PersonRole.Editor);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Inker);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker);
|
||||
await UpdatePeople(chapter, people, PersonRole.Inker);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Letterer);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer);
|
||||
await UpdatePeople(chapter, people, PersonRole.Letterer);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Penciller);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller);
|
||||
await UpdatePeople(chapter, people, PersonRole.Penciller);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.CoverArtist);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist);
|
||||
await UpdatePeople(chapter, people, PersonRole.CoverArtist);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Publisher);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher);
|
||||
await UpdatePeople(chapter, people, PersonRole.Publisher);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Imprint);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint);
|
||||
await UpdatePeople(chapter, people, PersonRole.Imprint);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Teams);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Team);
|
||||
await UpdatePeople(chapter, people, PersonRole.Team);
|
||||
|
||||
people = TagHelper.GetTagValues(comicInfo.Locations);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Location);
|
||||
await UpdatePeople(chapter, people, PersonRole.Location);
|
||||
|
||||
var genres = TagHelper.GetTagValues(comicInfo.Genre);
|
||||
GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres,
|
||||
genres.Select(g => new GenreBuilder(g).Build()).ToList());
|
||||
foreach (var genre in genres)
|
||||
if (!chapter.ColoristLocked)
|
||||
{
|
||||
var g = await _tagManagerService.GetGenre(genre);
|
||||
if (g == null) continue;
|
||||
chapter.Genres.Add(g);
|
||||
var people = TagHelper.GetTagValues(comicInfo.Colorist);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist);
|
||||
await UpdatePeople(chapter, people, PersonRole.Colorist);
|
||||
}
|
||||
|
||||
var tags = TagHelper.GetTagValues(comicInfo.Tags);
|
||||
TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => new TagBuilder(t).Build()).ToList());
|
||||
foreach (var tag in tags)
|
||||
if (!chapter.CharacterLocked)
|
||||
{
|
||||
var t = await _tagManagerService.GetTag(tag);
|
||||
if (t == null) continue;
|
||||
chapter.Tags.Add(t);
|
||||
var people = TagHelper.GetTagValues(comicInfo.Characters);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character);
|
||||
await UpdatePeople(chapter, people, PersonRole.Character);
|
||||
}
|
||||
|
||||
|
||||
if (!chapter.TranslatorLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Translator);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator);
|
||||
await UpdatePeople(chapter, people, PersonRole.Translator);
|
||||
}
|
||||
|
||||
if (!chapter.WriterLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Writer);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer);
|
||||
await UpdatePeople(chapter, people, PersonRole.Writer);
|
||||
}
|
||||
|
||||
if (!chapter.EditorLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Editor);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor);
|
||||
await UpdatePeople(chapter, people, PersonRole.Editor);
|
||||
}
|
||||
|
||||
if (!chapter.InkerLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Inker);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker);
|
||||
await UpdatePeople(chapter, people, PersonRole.Inker);
|
||||
}
|
||||
|
||||
if (!chapter.LettererLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Letterer);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer);
|
||||
await UpdatePeople(chapter, people, PersonRole.Letterer);
|
||||
}
|
||||
|
||||
if (!chapter.PencillerLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Penciller);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller);
|
||||
await UpdatePeople(chapter, people, PersonRole.Penciller);
|
||||
}
|
||||
|
||||
if (!chapter.CoverArtistLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.CoverArtist);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist);
|
||||
await UpdatePeople(chapter, people, PersonRole.CoverArtist);
|
||||
}
|
||||
|
||||
if (!chapter.PublisherLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Publisher);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher);
|
||||
await UpdatePeople(chapter, people, PersonRole.Publisher);
|
||||
}
|
||||
|
||||
if (!chapter.ImprintLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Imprint);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint);
|
||||
await UpdatePeople(chapter, people, PersonRole.Imprint);
|
||||
}
|
||||
|
||||
if (!chapter.TeamLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Teams);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Team);
|
||||
await UpdatePeople(chapter, people, PersonRole.Team);
|
||||
}
|
||||
|
||||
if (!chapter.LocationLocked)
|
||||
{
|
||||
var people = TagHelper.GetTagValues(comicInfo.Locations);
|
||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Location);
|
||||
await UpdatePeople(chapter, people, PersonRole.Location);
|
||||
}
|
||||
|
||||
|
||||
if (!chapter.GenresLocked)
|
||||
{
|
||||
var genres = TagHelper.GetTagValues(comicInfo.Genre);
|
||||
GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres,
|
||||
genres.Select(g => new GenreBuilder(g).Build()).ToList());
|
||||
foreach (var genre in genres)
|
||||
{
|
||||
var g = await _tagManagerService.GetGenre(genre);
|
||||
if (g == null) continue;
|
||||
chapter.Genres.Add(g);
|
||||
}
|
||||
}
|
||||
|
||||
if (!chapter.TagsLocked)
|
||||
{
|
||||
var tags = TagHelper.GetTagValues(comicInfo.Tags);
|
||||
TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => new TagBuilder(t).Build()).ToList());
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var t = await _tagManagerService.GetTag(tag);
|
||||
if (t == null) continue;
|
||||
chapter.Tags.Add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,6 +138,14 @@ public static class MessageFactory
|
||||
/// A Progress event when a smart collection is synchronizing
|
||||
/// </summary>
|
||||
public const string SmartCollectionSync = "SmartCollectionSync";
|
||||
/// <summary>
|
||||
/// Chapter is removed from server
|
||||
/// </summary>
|
||||
public const string ChapterRemoved = "ChapterRemoved";
|
||||
/// <summary>
|
||||
/// Volume is removed from server
|
||||
/// </summary>
|
||||
public const string VolumeRemoved = "VolumeRemoved";
|
||||
|
||||
public static SignalRMessage DashboardUpdateEvent(int userId)
|
||||
{
|
||||
@ -213,6 +221,32 @@ public static class MessageFactory
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage ChapterRemovedEvent(int chapterId, int seriesId)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = ChapterRemoved,
|
||||
Body = new
|
||||
{
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapterId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static SignalRMessage VolumeRemovedEvent(int volumeId, int seriesId)
|
||||
{
|
||||
return new SignalRMessage()
|
||||
{
|
||||
Name = VolumeRemoved,
|
||||
Body = new
|
||||
{
|
||||
SeriesId = seriesId,
|
||||
VolumeId = volumeId
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public static SignalRMessage WordCountAnalyzerProgressEvent(int libraryId, float progress, string eventType, string subtitle = "")
|
||||
{
|
||||
|
36
UI/Web/package-lock.json
generated
36
UI/Web/package-lock.json
generated
@ -504,6 +504,7 @@
|
||||
"version": "17.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
|
||||
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -531,6 +532,7 @@
|
||||
"version": "7.23.9",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
|
||||
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@ -559,12 +561,14 @@
|
||||
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@ -745,6 +749,7 @@
|
||||
"version": "7.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
|
||||
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.23.5",
|
||||
@ -773,12 +778,14 @@
|
||||
"node_modules/@babel/core/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/core/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
@ -5622,6 +5629,7 @@
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
@ -5634,6 +5642,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -5905,6 +5914,7 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
@ -6216,6 +6226,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
@ -6507,7 +6518,8 @@
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
@ -7409,6 +7421,7 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@ -7418,6 +7431,7 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -8526,6 +8540,7 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
@ -9207,6 +9222,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
@ -11047,6 +11063,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -12436,6 +12453,7 @@
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
@ -12447,6 +12465,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@ -12457,7 +12476,8 @@
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/regenerate": {
|
||||
"version": "1.4.2",
|
||||
@ -12925,7 +12945,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"devOptional": true
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.71.1",
|
||||
@ -13044,6 +13064,7 @@
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
@ -13058,6 +13079,7 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
@ -13068,7 +13090,8 @@
|
||||
"node_modules/semver/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.18.0",
|
||||
@ -14199,6 +14222,7 @@
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
203
UI/Web/src/_card-item-common.scss
Normal file
203
UI/Web/src/_card-item-common.scss
Normal file
@ -0,0 +1,203 @@
|
||||
|
||||
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.error-banner {
|
||||
width: $image-width;
|
||||
height: 18px;
|
||||
background-color: var(--toast-error-bg-color);
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.selected-highlight {
|
||||
outline: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
.progress-banner {
|
||||
width: $image-width;
|
||||
height: 5px;
|
||||
|
||||
.progress {
|
||||
color: var(--card-progress-bar-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 30%;
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
height: $image-height;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 158px;
|
||||
}
|
||||
|
||||
.not-read-badge {
|
||||
position: absolute;
|
||||
top: calc(-1 * (var(--card-progress-triangle-size) / 2));
|
||||
right: -14px;
|
||||
z-index: 1000;
|
||||
height: var(--card-progress-triangle-size);
|
||||
width: var(--card-progress-triangle-size);
|
||||
background-color: var(--primary-color);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
.bulk-mode {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
visibility: hidden;
|
||||
|
||||
&.always-show {
|
||||
visibility: visible !important;
|
||||
width: $image-width;
|
||||
height: $image-height;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--checkbox-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
.meta-title {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.overlay {
|
||||
&:hover {
|
||||
.bulk-mode {
|
||||
visibility: visible;
|
||||
z-index: 110;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
visibility: visible;
|
||||
|
||||
.overlay-information {
|
||||
visibility: visible;
|
||||
display: block;
|
||||
}
|
||||
|
||||
& + .meta-title {
|
||||
display: -webkit-box;
|
||||
visibility: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-information {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 230px;
|
||||
transition: all 0.2s;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--card-overlay-hover-bg-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-information--centered {
|
||||
position: absolute;
|
||||
border-radius: 15px;
|
||||
background-color: rgba(0, 0, 0, .7);
|
||||
border-radius: 50px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 115;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 115;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.library {
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-body > div:nth-child(2) {
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.overlay-information {
|
||||
visibility: hidden;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chapter,
|
||||
.volume,
|
||||
.series,
|
||||
.expected {
|
||||
.overlay-information--centered {
|
||||
div {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
i {
|
||||
font-size: 1.4rem;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
121
UI/Web/src/_series-detail-common.scss
Normal file
121
UI/Web/src/_series-detail-common.scss
Normal file
@ -0,0 +1,121 @@
|
||||
.title {
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
align-self: flex-start;
|
||||
max-height: 400px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: lightgrey;
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
//padding: 4px 8px !important;
|
||||
//font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.btn-group > .btn.dropdown-toggle-split:not(first-child){
|
||||
border-top-right-radius: var(--bs-border-radius) !important;
|
||||
border-bottom-right-radius: var(--bs-border-radius) !important;
|
||||
border-width: 1px 1px 1px 0 !important;
|
||||
}
|
||||
|
||||
.btn-group > .btn:not(:last-child):not(.dropdown-toggle) {
|
||||
border-width: 1px 0 1px 1px !important;
|
||||
}
|
||||
|
||||
.card-body > div:nth-child(2) {
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.under-image ~ .overlay-information {
|
||||
top: -404px;
|
||||
height: 364px;
|
||||
}
|
||||
|
||||
.overlay-information {
|
||||
position: relative;
|
||||
top: -364px;
|
||||
height: 364px;
|
||||
transition: all 0.2s;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--card-overlay-hover-bg-color) !important;
|
||||
|
||||
.overlay-information--centered {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-information--centered {
|
||||
position: absolute;
|
||||
border-radius: 15px;
|
||||
background-color: rgba(0, 0, 0, .7);
|
||||
border-radius: 50px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 115;
|
||||
visibility: hidden;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-color) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
i {
|
||||
font-size: 1.6rem;
|
||||
line-height: 60px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.progress-banner.series {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
::ng-deep .progress-banner.series span {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: white;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.carousel-tabs-container {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.carousel-tabs-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.nav-tabs {
|
||||
flex-wrap: nowrap;
|
||||
}
|
@ -4,6 +4,9 @@ import {PublicationStatus} from "./metadata/publication-status";
|
||||
import {Genre} from "./metadata/genre";
|
||||
import {Tag} from "./tag";
|
||||
import {Person} from "./metadata/person";
|
||||
import {IHasCast} from "./common/i-has-cast";
|
||||
import {IHasReadingTime} from "./common/i-has-reading-time";
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
|
||||
export const LooseLeafOrDefaultNumber = -100000;
|
||||
export const SpecialVolumeNumber = 100000;
|
||||
@ -11,7 +14,7 @@ export const SpecialVolumeNumber = 100000;
|
||||
/**
|
||||
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
|
||||
*/
|
||||
export interface Chapter {
|
||||
export interface Chapter extends IHasCast, IHasReadingTime, IHasCover {
|
||||
id: number;
|
||||
range: string;
|
||||
/**
|
||||
@ -56,30 +59,51 @@ export interface Chapter {
|
||||
lastReadingProgress: string;
|
||||
sortOrder: number;
|
||||
|
||||
// originally in ChapterMetadata but now inlined with Chapter data
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
|
||||
year: string;
|
||||
language: string;
|
||||
publicationStatus: PublicationStatus;
|
||||
count: number;
|
||||
totalCount: number;
|
||||
year: string;
|
||||
language: string;
|
||||
publicationStatus: PublicationStatus;
|
||||
count: number;
|
||||
totalCount: number;
|
||||
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
imprints: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
teams: Array<Person>;
|
||||
locations: Array<Person>;
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
imprints: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
teams: Array<Person>;
|
||||
locations: Array<Person>;
|
||||
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
summaryLocked: boolean;
|
||||
genresLocked: boolean;
|
||||
tagsLocked: boolean;
|
||||
writerLocked: boolean;
|
||||
coverArtistLocked: boolean;
|
||||
publisherLocked: boolean;
|
||||
characterLocked: boolean;
|
||||
pencillerLocked: boolean;
|
||||
inkerLocked: boolean;
|
||||
imprintLocked: boolean;
|
||||
coloristLocked: boolean;
|
||||
lettererLocked: boolean;
|
||||
editorLocked: boolean;
|
||||
translatorLocked: boolean;
|
||||
teamLocked: boolean;
|
||||
locationLocked: boolean;
|
||||
ageRatingLocked: boolean;
|
||||
languageLocked: boolean;
|
||||
isbnLocked: boolean;
|
||||
titleNameLocked: boolean;
|
||||
sortOrderLocked: boolean;
|
||||
releaseDateLocked: boolean;
|
||||
}
|
||||
|
50
UI/Web/src/app/_models/common/i-has-cast.ts
Normal file
50
UI/Web/src/app/_models/common/i-has-cast.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import {Person} from "../metadata/person";
|
||||
|
||||
export interface IHasCast {
|
||||
writerLocked: boolean;
|
||||
coverArtistLocked: boolean;
|
||||
publisherLocked: boolean;
|
||||
characterLocked: boolean;
|
||||
pencillerLocked: boolean;
|
||||
inkerLocked: boolean;
|
||||
imprintLocked: boolean;
|
||||
coloristLocked: boolean;
|
||||
lettererLocked: boolean;
|
||||
editorLocked: boolean;
|
||||
translatorLocked: boolean;
|
||||
teamLocked: boolean;
|
||||
locationLocked: boolean;
|
||||
languageLocked: boolean;
|
||||
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
imprints: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
teams: Array<Person>;
|
||||
locations: Array<Person>;
|
||||
}
|
||||
|
||||
export function hasAnyCast(entity: IHasCast | null | undefined): boolean {
|
||||
if (entity === null || entity === undefined) return false;
|
||||
|
||||
return entity.writers.length > 0 ||
|
||||
entity.coverArtists.length > 0 ||
|
||||
entity.publishers.length > 0 ||
|
||||
entity.characters.length > 0 ||
|
||||
entity.pencillers.length > 0 ||
|
||||
entity.inkers.length > 0 ||
|
||||
entity.imprints.length > 0 ||
|
||||
entity.colorists.length > 0 ||
|
||||
entity.letterers.length > 0 ||
|
||||
entity.editors.length > 0 ||
|
||||
entity.translators.length > 0 ||
|
||||
entity.teams.length > 0 ||
|
||||
entity.locations.length > 0;
|
||||
}
|
5
UI/Web/src/app/_models/common/i-has-cover.ts
Normal file
5
UI/Web/src/app/_models/common/i-has-cover.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface IHasCover {
|
||||
coverImage?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
}
|
8
UI/Web/src/app/_models/common/i-has-reading-time.ts
Normal file
8
UI/Web/src/app/_models/common/i-has-reading-time.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface IHasReadingTime {
|
||||
minHoursToRead: number;
|
||||
maxHoursToRead: number;
|
||||
avgHoursToRead: number;
|
||||
pages: number;
|
||||
wordCount: number;
|
||||
|
||||
}
|
4
UI/Web/src/app/_models/events/chapter-removed-event.ts
Normal file
4
UI/Web/src/app/_models/events/chapter-removed-event.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface ChapterRemovedEvent {
|
||||
chapterId: number;
|
||||
seriesId: number;
|
||||
}
|
4
UI/Web/src/app/_models/events/volume-removed-event.ts
Normal file
4
UI/Web/src/app/_models/events/volume-removed-event.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface VolumeRemovedEvent {
|
||||
volumeId: number;
|
||||
seriesId: number;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import {FileTypeGroup} from "./file-type-group.enum";
|
||||
import {IHasCover} from "../common/i-has-cover";
|
||||
|
||||
export enum LibraryType {
|
||||
Manga = 0,
|
||||
|
@ -3,8 +3,9 @@ import { AgeRating } from "./age-rating";
|
||||
import { PublicationStatus } from "./publication-status";
|
||||
import { Person } from "./person";
|
||||
import { Tag } from "../tag";
|
||||
import {IHasCast} from "../common/i-has-cast";
|
||||
|
||||
export interface SeriesMetadata {
|
||||
export interface SeriesMetadata extends IHasCast {
|
||||
seriesId: number;
|
||||
summary: string;
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { LibraryType } from "./library/library";
|
||||
import { MangaFormat } from "./manga-format";
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
|
||||
export interface ReadingListItem {
|
||||
pagesRead: number;
|
||||
@ -20,7 +21,7 @@ export interface ReadingListItem {
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface ReadingList {
|
||||
export interface ReadingList extends IHasCover {
|
||||
id: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
@ -30,9 +31,9 @@ export interface ReadingList {
|
||||
/**
|
||||
* If this is empty or null, the cover image isn't set. Do not use this externally.
|
||||
*/
|
||||
coverImage: string;
|
||||
primaryColor?: string;
|
||||
secondaryColor?: string;
|
||||
coverImage?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
startingYear: number;
|
||||
startingMonth: number;
|
||||
endingYear: number;
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { MangaFormat } from './manga-format';
|
||||
import { Volume } from './volume';
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
import {IHasReadingTime} from "./common/i-has-reading-time";
|
||||
|
||||
export interface Series {
|
||||
export interface Series extends IHasCover, IHasReadingTime {
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
@ -60,11 +62,11 @@ export interface Series {
|
||||
* Highest level folder containing this series
|
||||
*/
|
||||
folderPath: string;
|
||||
/**
|
||||
* This is currently only used on Series detail page for recommendations
|
||||
*/
|
||||
summary?: string;
|
||||
coverImage?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
/**
|
||||
* This is currently only used on Series detail page for recommendations
|
||||
*/
|
||||
summary?: string;
|
||||
coverImage?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { Chapter } from './chapter';
|
||||
import { HourEstimateRange } from './series-detail/hour-estimate-range';
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
import {IHasReadingTime} from "./common/i-has-reading-time";
|
||||
|
||||
export interface Volume {
|
||||
export interface Volume extends IHasCover, IHasReadingTime {
|
||||
id: number;
|
||||
minNumber: number;
|
||||
maxNumber: number;
|
||||
@ -10,6 +12,7 @@ export interface Volume {
|
||||
lastModifiedUtc: string;
|
||||
pages: number;
|
||||
pagesRead: number;
|
||||
wordCount: number;
|
||||
chapters: Array<Chapter>;
|
||||
/**
|
||||
* This is only available on the object when fetched for SeriesDetail
|
||||
@ -20,6 +23,7 @@ export interface Volume {
|
||||
avgHoursToRead: number;
|
||||
|
||||
coverImage?: string;
|
||||
coverImageLocked: boolean;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
}
|
||||
|
16
UI/Web/src/app/_pipes/read-time-left.pipe.ts
Normal file
16
UI/Web/src/app/_pipes/read-time-left.pipe.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||
|
||||
@Pipe({
|
||||
name: 'readTimeLeft',
|
||||
standalone: true
|
||||
})
|
||||
export class ReadTimeLeftPipe implements PipeTransform {
|
||||
|
||||
constructor(private translocoService: TranslocoService) {}
|
||||
|
||||
transform(readingTimeLeft: HourEstimateRange): string {
|
||||
return `~${readingTimeLeft.avgHours} ${readingTimeLeft.avgHours > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`;
|
||||
}
|
||||
}
|
21
UI/Web/src/app/_pipes/read-time.pipe.ts
Normal file
21
UI/Web/src/app/_pipes/read-time.pipe.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {IHasReadingTime} from "../_models/common/i-has-reading-time";
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'readTime',
|
||||
standalone: true
|
||||
})
|
||||
export class ReadTimePipe implements PipeTransform {
|
||||
constructor(private translocoService: TranslocoService) {}
|
||||
|
||||
transform(readingTime: IHasReadingTime): string {
|
||||
if (readingTime.maxHoursToRead === 0 || readingTime.minHoursToRead === 0) {
|
||||
return this.translocoService.translate('read-time-pipe.less-than-hour');
|
||||
} else {
|
||||
return `${readingTime.minHoursToRead}${readingTime.maxHoursToRead !== readingTime.minHoursToRead ? ('-' + readingTime.maxHoursToRead) : ''}` +
|
||||
` ${readingTime.minHoursToRead > 1 ? this.translocoService.translate('read-time-pipe.hours') : this.translocoService.translate('read-time-pipe.hour')}`;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -239,6 +239,7 @@ export class ActionFactoryService {
|
||||
}
|
||||
|
||||
for (let child of parent.children) {
|
||||
if (child.action === Action.SendTo) continue;
|
||||
actionItem = {...child};
|
||||
actionItem.title = translate('actionable.' + actionItem.title);
|
||||
if (actionItem.description !== '') {
|
||||
@ -413,6 +414,22 @@ export class ActionFactoryService {
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
// {
|
||||
// action: Action.AddToScrobbleHold,
|
||||
// title: 'add-to-scrobble-hold',
|
||||
// description: 'add-to-scrobble-hold-tooltip',
|
||||
// callback: this.dummyCallback,
|
||||
// requiresAdmin: true,
|
||||
// children: [],
|
||||
// },
|
||||
// {
|
||||
// action: Action.RemoveFromScrobbleHold,
|
||||
// title: 'remove-from-scrobble-hold',
|
||||
// description: 'remove-from-scrobble-hold-tooltip',
|
||||
// callback: this.dummyCallback,
|
||||
// requiresAdmin: true,
|
||||
// children: [],
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -558,12 +575,29 @@ export class ActionFactoryService {
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
action: Action.Submenu,
|
||||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
@ -639,16 +673,33 @@ export class ActionFactoryService {
|
||||
},
|
||||
// RBS will handle rendering this, so non-admins with download are applicable
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
action: Action.Submenu,
|
||||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'details',
|
||||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import {Subject, tap} from 'rxjs';
|
||||
@ -25,6 +25,8 @@ import {CollectionTagService} from "./collection-tag.service";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
import {FilterService} from "./filter.service";
|
||||
import {ReadingListService} from "./reading-list.service";
|
||||
import {ChapterService} from "./chapter.service";
|
||||
import {VolumeService} from "./volume.service";
|
||||
|
||||
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
||||
export type SeriesActionCallback = (series: Series) => void;
|
||||
@ -40,22 +42,26 @@ export type BooleanActionCallback = (result: boolean) => void;
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ActionService implements OnDestroy {
|
||||
export class ActionService {
|
||||
|
||||
private readonly chapterService = inject(ChapterService);
|
||||
private readonly volumeService = inject(VolumeService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly memberService = inject(MemberService);
|
||||
private readonly deviceService = inject(DeviceService);
|
||||
private readonly collectionTagService = inject(CollectionTagService);
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly readingListService = inject(ReadingListService);
|
||||
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
private readingListModalRef: NgbModalRef | null = null;
|
||||
private collectionModalRef: NgbModalRef | null = null;
|
||||
|
||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
|
||||
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
|
||||
private readonly collectionTagService: CollectionTagService, private filterService: FilterService,
|
||||
private readonly readingListService: ReadingListService) { }
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a file scan for a given Library
|
||||
@ -290,6 +296,7 @@ export class ActionService implements OnDestroy {
|
||||
|
||||
/**
|
||||
* Mark a chapter as read
|
||||
* @param libraryId Library Id
|
||||
* @param seriesId Series Id
|
||||
* @param chapter Chapter, should have id, pages, volumeId populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
@ -306,6 +313,7 @@ export class ActionService implements OnDestroy {
|
||||
|
||||
/**
|
||||
* Mark a chapter as unread
|
||||
* @param libraryId Library Id
|
||||
* @param seriesId Series Id
|
||||
* @param chapter Chapter, should have id, pages, volumeId populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
@ -324,7 +332,7 @@ export class ActionService implements OnDestroy {
|
||||
* Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series
|
||||
* @param seriesId Series Id
|
||||
* @param volumes Volumes, should have id, chapters and pagesRead populated
|
||||
* @param chapters? Chapters, should have id
|
||||
* @param chapters Optional Chapters, should have id
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markMultipleAsRead(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
@ -346,6 +354,7 @@ export class ActionService implements OnDestroy {
|
||||
* Mark all chapters and the volumes as Unread. All volumes must belong to a series
|
||||
* @param seriesId Series Id
|
||||
* @param volumes Volumes, should have id, chapters and pagesRead populated
|
||||
* @param chapters Optional Chapters, should have id
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markMultipleAsUnread(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
@ -690,6 +699,48 @@ export class ActionService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChapter(chapterId: number, callback?: BooleanActionCallback) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-chapter'))) {
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.chapterService.deleteChapter(chapterId).subscribe((res: boolean) => {
|
||||
if (callback) {
|
||||
if (res) {
|
||||
this.toastr.success(translate('toasts.chapter-deleted'));
|
||||
} else {
|
||||
this.toastr.error(translate('errors.generic'));
|
||||
}
|
||||
|
||||
callback(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteVolume(volumeId: number, callback?: BooleanActionCallback) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-volume'))) {
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.volumeService.deleteVolume(volumeId).subscribe((res: boolean) => {
|
||||
if (callback) {
|
||||
if (res) {
|
||||
this.toastr.success(translate('toasts.volume-deleted'));
|
||||
} else {
|
||||
this.toastr.error(translate('errors.generic'));
|
||||
}
|
||||
|
||||
callback(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendToDevice(chapterIds: Array<number>, device: Device, callback?: VoidActionCallback) {
|
||||
this.deviceService.sendTo(chapterIds, device.id).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.file-send-to', {name: device.name}));
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {AccountService} from "./account.service";
|
||||
import {UserCollection} from "../_models/collection-tag";
|
||||
import {Chapter} from "../_models/chapter";
|
||||
import {HourEstimateRange} from "../_models/series-detail/hour-estimate-range";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -16,7 +14,15 @@ export class ChapterService {
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getChapterMetadata(chapterId: number) {
|
||||
return this.httpClient.get<Chapter>(this.baseUrl + 'chapter/?chapterId=' + chapterId);
|
||||
return this.httpClient.get<Chapter>(this.baseUrl + 'chapter?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
deleteChapter(chapterId: number) {
|
||||
return this.httpClient.delete<boolean>(this.baseUrl + 'chapter?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
updateChapter(chapter: Chapter) {
|
||||
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -16,6 +16,8 @@ export enum EVENTS {
|
||||
ScanSeries = 'ScanSeries',
|
||||
SeriesAdded = 'SeriesAdded',
|
||||
SeriesRemoved = 'SeriesRemoved',
|
||||
VolumeRemoved = 'VolumeRemoved',
|
||||
ChapterRemoved = 'ChapterRemoved',
|
||||
ScanLibraryProgress = 'ScanLibraryProgress',
|
||||
OnlineUsers = 'OnlineUsers',
|
||||
/**
|
||||
@ -293,6 +295,20 @@ export class MessageHubService {
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.ChapterRemoved, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.ChapterRemoved,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.VolumeRemoved, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.VolumeRemoved,
|
||||
payload: resp.body
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.CoverUpdate, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.CoverUpdate,
|
||||
|
@ -11,13 +11,13 @@ import {Person, PersonRole} from '../_models/metadata/person';
|
||||
import {Tag} from '../_models/tag';
|
||||
import {FilterComparison} from '../_models/metadata/v2/filter-comparison';
|
||||
import {FilterField} from '../_models/metadata/v2/filter-field';
|
||||
import {Router} from "@angular/router";
|
||||
import {SortField} from "../_models/metadata/series-filter";
|
||||
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
|
||||
import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
|
||||
import {LibraryType} from "../_models/library/library";
|
||||
import {IHasCast} from "../_models/common/i-has-cast";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -126,4 +126,52 @@ export class MetadataService {
|
||||
arr[index].field = filterStmt.field;
|
||||
arr[index].value = filterStmt.value ? filterStmt.value + '' : '';
|
||||
}
|
||||
|
||||
updatePerson(entity: IHasCast, persons: Person[], role: PersonRole) {
|
||||
switch (role) {
|
||||
case PersonRole.Other:
|
||||
break;
|
||||
case PersonRole.Artist:
|
||||
break;
|
||||
case PersonRole.CoverArtist:
|
||||
entity.coverArtists = persons;
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
entity.characters = persons;
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
entity.colorists = persons;
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
entity.editors = persons;
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
entity.inkers = persons;
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
entity.letterers = persons;
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
entity.pencillers = persons;
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
entity.publishers = persons;
|
||||
break;
|
||||
case PersonRole.Imprint:
|
||||
entity.imprints = persons;
|
||||
break;
|
||||
case PersonRole.Team:
|
||||
entity.teams = persons;
|
||||
break;
|
||||
case PersonRole.Location:
|
||||
entity.locations = persons;
|
||||
break;
|
||||
case PersonRole.Writer:
|
||||
entity.writers = persons;
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
entity.translators = persons;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,10 @@ import {PersonalToC} from "../_models/readers/personal-toc";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import NoSleep from 'nosleep.js';
|
||||
import {FullProgress} from "../_models/readers/full-progress";
|
||||
import {Volume} from "../_models/volume";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
|
||||
export const CHAPTER_ID_DOESNT_EXIST = -1;
|
||||
@ -30,6 +34,12 @@ export const CHAPTER_ID_NOT_FETCHED = -2;
|
||||
export class ReaderService {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly location = inject(Location);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
encodedKey: string = '';
|
||||
|
||||
@ -38,9 +48,7 @@ export class ReaderService {
|
||||
|
||||
private noSleep = new NoSleep();
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router,
|
||||
private location: Location, private accountService: AccountService,
|
||||
@Inject(DOCUMENT) private document: Document) {
|
||||
constructor(private httpClient: HttpClient, @Inject(DOCUMENT) private document: Document) {
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
if (user) {
|
||||
this.encodedKey = encodeURIComponent(user.apiKey);
|
||||
@ -353,4 +361,30 @@ export class ReaderService {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) {
|
||||
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
|
||||
// Find the continue point chapter and load it
|
||||
const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages);
|
||||
if (unreadChapters.length > 0) {
|
||||
this.readChapter(libraryId, seriesId, unreadChapters[0], incognitoMode);
|
||||
return;
|
||||
}
|
||||
this.readChapter(libraryId, seriesId, volume.chapters[0], incognitoMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort the chapters, then grab first if no reading progress
|
||||
this.readChapter(libraryId, seriesId, [...volume.chapters].sort(this.utilityService.sortChapters)[0]);
|
||||
}
|
||||
|
||||
readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) {
|
||||
if (chapter.pages === 0) {
|
||||
this.toastr.error(translate('series-detail.no-pages'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
|
||||
{queryParams: {incognitoMode}});
|
||||
}
|
||||
}
|
||||
|
@ -39,6 +39,10 @@ export class ReadingListService {
|
||||
return this.httpClient.get<ReadingList[]>(this.baseUrl + 'readinglist/lists-for-series?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
getReadingListsForChapter(chapterId: number) {
|
||||
return this.httpClient.get<ReadingList[]>(this.baseUrl + 'readinglist/lists-for-chapter?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
getListItems(readingListId: number) {
|
||||
return this.httpClient.get<ReadingListItem[]>(this.baseUrl + 'readinglist/items?readingListId=' + readingListId);
|
||||
}
|
||||
|
@ -167,10 +167,6 @@ export class ThemeService {
|
||||
}));
|
||||
}
|
||||
|
||||
scan() {
|
||||
return this.httpClient.post(this.baseUrl + 'theme/scan', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the book theme on the body tag so css variable overrides can take place
|
||||
* @param selector brtheme- prefixed string
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {tap} from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -9,6 +12,7 @@ import { TextResonse } from '../_types/text-response';
|
||||
export class UploadService {
|
||||
|
||||
private baseUrl = environment.apiUrl;
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
@ -18,29 +22,46 @@ export class UploadService {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param seriesId Series to overwrite cover image for
|
||||
* @param url A base64 encoded url
|
||||
* @returns
|
||||
* @param lockCover Should the cover be locked or not
|
||||
* @returns
|
||||
*/
|
||||
updateSeriesCoverImage(seriesId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url)});
|
||||
updateSeriesCoverImage(seriesId: number, url: string, lockCover: boolean = true) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
|
||||
this.toastr.info(translate('series-detail.cover-change'));
|
||||
}));
|
||||
}
|
||||
|
||||
updateCollectionCoverImage(tagId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)});
|
||||
updateCollectionCoverImage(tagId: number, url: string, lockCover: boolean = true) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
|
||||
this.toastr.info(translate('series-detail.cover-change'));
|
||||
}));
|
||||
}
|
||||
|
||||
updateReadingListCoverImage(readingListId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url)});
|
||||
updateReadingListCoverImage(readingListId: number, url: string, lockCover: boolean = true) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/reading-list', {id: readingListId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
|
||||
this.toastr.info(translate('series-detail.cover-change'));
|
||||
}));
|
||||
}
|
||||
|
||||
updateChapterCoverImage(chapterId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)});
|
||||
updateChapterCoverImage(chapterId: number, url: string, lockCover: boolean = true) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
|
||||
this.toastr.info(translate('series-detail.cover-change'));
|
||||
}));
|
||||
}
|
||||
|
||||
updateLibraryCoverImage(libraryId: number, url: string) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url)});
|
||||
updateVolumeCoverImage(volumeId: number, url: string, lockCover: boolean = true) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/volume', {id: volumeId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
|
||||
this.toastr.info(translate('series-detail.cover-change'));
|
||||
}));
|
||||
}
|
||||
|
||||
updateLibraryCoverImage(libraryId: number, url: string, lockCover: boolean = true) {
|
||||
return this.httpClient.post<number>(this.baseUrl + 'upload/library', {id: libraryId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => {
|
||||
this.toastr.info(translate('series-detail.cover-change'));
|
||||
}));
|
||||
}
|
||||
|
||||
resetChapterCoverLock(chapterId: number, ) {
|
||||
|
27
UI/Web/src/app/_services/volume.service.ts
Normal file
27
UI/Web/src/app/_services/volume.service.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Volume} from "../_models/volume";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class VolumeService {
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
getVolumeMetadata(volumeId: number) {
|
||||
return this.httpClient.get<Volume>(this.baseUrl + 'volume?volumeId=' + volumeId);
|
||||
}
|
||||
|
||||
deleteVolume(volumeId: number) {
|
||||
return this.httpClient.delete<boolean>(this.baseUrl + 'volume?volumeId=' + volumeId);
|
||||
}
|
||||
|
||||
updateVolume(volume: any) {
|
||||
return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse);
|
||||
}
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
<ng-container *transloco="let t; read: 'details-tab'">
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="genres" [title]="t('genres-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-tag-badge (click)="openGeneric(FilterField.Genres, item.id)" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
{{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="tags" [title]="t('tags-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-tag-badge (click)="openGeneric(FilterField.Tags, item.id)" [selectionMode]="TagBadgeCursor.Clickable">
|
||||
{{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
@if (genres.length > 0 || tags.length > 0) {
|
||||
<div class="setting-section-break" aria-hidden="true"></div>
|
||||
}
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.writers" [title]="t('writers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Writers, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.colorists" [title]="t('colorists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Colorist, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.editors" [title]="t('editors-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Editor, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.coverArtists" [title]="t('cover-artists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.CoverArtist, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.inkers" [title]="t('inkers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Inker, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.letterers" [title]="t('letterers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Letterer, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.pencillers" [title]="t('pencillers-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Penciller, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.translators" [title]="t('translators-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Translators, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.characters" [title]="t('characters-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Characters, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.locations" [title]="t('locations-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Location, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.teams" [title]="t('teams-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Team, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="metadata.imprints" [title]="t('imprints-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-person-badge [person]="item" (click)="openPerson(FilterField.Imprint, item)"></app-person-badge>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
</ng-container>
|
@ -0,0 +1,52 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {IHasCast} from "../../_models/common/i-has-cast";
|
||||
import {Person, PersonRole} from "../../_models/metadata/person";
|
||||
import {Router} from "@angular/router";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {FilterComparison} from "../../_models/metadata/v2/filter-comparison";
|
||||
import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.service";
|
||||
import {Genre} from "../../_models/metadata/genre";
|
||||
import {Tag} from "../../_models/tag";
|
||||
import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badge.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-details-tab',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CarouselReelComponent,
|
||||
PersonBadgeComponent,
|
||||
TranslocoDirective,
|
||||
TagBadgeComponent
|
||||
],
|
||||
templateUrl: './details-tab.component.html',
|
||||
styleUrl: './details-tab.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DetailsTabComponent {
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
protected readonly PersonRole = PersonRole;
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
@Input({required: true}) metadata!: IHasCast;
|
||||
@Input() genres: Array<Genre> = [];
|
||||
@Input() tags: Array<Tag> = [];
|
||||
|
||||
|
||||
openPerson(queryParamName: FilterField, filter: Person) {
|
||||
if (queryParamName === FilterField.None) return;
|
||||
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter.id}`).subscribe();
|
||||
}
|
||||
|
||||
openGeneric(queryParamName: FilterField, filter: string | number) {
|
||||
if (queryParamName === FilterField.None) return;
|
||||
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
|
||||
}
|
||||
|
||||
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
}
|
@ -0,0 +1,610 @@
|
||||
<ng-container *transloco="let t; read: 'edit-chapter-modal'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title></h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal" [ngClass]="{'d-flex': utilityService.getActiveBreakpoint() !== Breakpoint.Mobile}">
|
||||
|
||||
<form [formGroup]="editForm">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
|
||||
<!-- General Tab -->
|
||||
<li [ngbNavItem]="TabID.General">
|
||||
<a ngbNavLink>{{t(TabID.General)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="col-md-9 col-sm-12 mb-3">
|
||||
<app-setting-item [title]="t('title-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||
<ng-template #view>
|
||||
@if (editForm.get('titleName'); as formControl) {
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.titleNameLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'titleNameLocked' }"></ng-container>
|
||||
<input class="form-control" formControlName="titleName" type="text"
|
||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||
@if (formControl.errors; as errors) {
|
||||
<div class="invalid-feedback">
|
||||
@if (errors.required) {
|
||||
<div>{{t('required-field')}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-12 mb-3">
|
||||
<app-setting-item [title]="t('sort-order-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||
<ng-template #view>
|
||||
@if (editForm.get('sortOrder'); as formControl) {
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.sortOrderLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'sortOrderLocked' }"></ng-container>
|
||||
<input class="form-control" formControlName="sortOrder" type="number" min="0" step="0.1" inputmode="numeric"
|
||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||
@if (formControl.errors; as errors) {
|
||||
<div class="invalid-feedback">
|
||||
@if (errors.required) {
|
||||
<div>{{t('required-field')}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-9 col-sm-12 mb-3">
|
||||
<app-setting-item [title]="t('isbn-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||
<ng-template #view>
|
||||
@if (editForm.get('isbn'); as formControl) {
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.isbnLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'isbnLocked' }"></ng-container>
|
||||
<input class="form-control" formControlName="isbn" type="text"
|
||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||
@if (formControl.errors; as errors) {
|
||||
<div class="invalid-feedback">
|
||||
@if (errors.required) {
|
||||
<div>{{t('required-field')}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-12 mb-3">
|
||||
<app-setting-item [title]="t('age-rating-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||
<ng-template #view>
|
||||
@if (editForm.get('ageRating'); as formControl) {
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.ageRatingLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'ageRatingLocked' }"></ng-container>
|
||||
<select class="form-select" id="age-rating" formControlName="ageRating">
|
||||
@for(opt of ageRatings; track opt.value) {
|
||||
<option [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-9 col-md-12">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
|
||||
[(locked)]="chapter.languageLocked" (onUnlock)="chapter.languageLocked = false"
|
||||
(newItemAdded)="chapter.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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-12">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('release-date-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.releaseDateLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'releaseDateLocked' }"></ng-container>
|
||||
<input
|
||||
class="form-control"
|
||||
formControlName="releaseDate"
|
||||
type="date"
|
||||
/>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<app-setting-item [title]="t('summary-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||
<ng-template #view>
|
||||
@if (editForm.get('summary'); as formControl) {
|
||||
<div class="input-group" [ngClass]="{'lock-active': chapter.summaryLocked}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'summaryLocked' }"></ng-container>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Tags Tab -->
|
||||
<li [ngbNavItem]="TabID.Tags">
|
||||
<a ngbNavLink>{{t(TabID.Tags)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<!-- genre & tag -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updateGenres($event);chapter.genresLocked = true" [settings]="genreSettings"
|
||||
[(locked)]="chapter.genresLocked" (onUnlock)="chapter.genresLocked = false"
|
||||
(newItemAdded)="chapter.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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updateTags($event);chapter.tagsLocked = true" [settings]="tagsSettings"
|
||||
[(locked)]="chapter.tagsLocked" (onUnlock)="chapter.tagsLocked = false"
|
||||
(newItemAdded)="chapter.tagsLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- imprint & publisher -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);chapter.imprintLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
|
||||
[(locked)]="chapter.imprintLocked" (onUnlock)="chapter.imprintLocked = false"
|
||||
(newItemAdded)="chapter.imprintLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);chapter.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
||||
[(locked)]="chapter.publisherLocked" (onUnlock)="chapter.publisherLocked = false"
|
||||
(newItemAdded)="chapter.publisherLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- team & location -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);chapter.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
|
||||
[(locked)]="chapter.teamLocked" (onUnlock)="chapter.teamLocked = false"
|
||||
(newItemAdded)="chapter.teamLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);chapter.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
|
||||
[(locked)]="chapter.locationLocked" (onUnlock)="chapter.locationLocked = false"
|
||||
(newItemAdded)="chapter.locationLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- character -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-12 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);chapter.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
|
||||
[(locked)]="chapter.characterLocked" (onUnlock)="chapter.characterLocked = false"
|
||||
(newItemAdded)="chapter.characterLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- People Tab -->
|
||||
<li [ngbNavItem]="TabID.People">
|
||||
<a ngbNavLink>{{t(TabID.People)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<!-- writer & cover artist -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);chapter.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
|
||||
[(locked)]="chapter.writerLocked" (onUnlock)="chapter.writerLocked = false"
|
||||
(newItemAdded)="chapter.writerLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);chapter.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
||||
[(locked)]="chapter.coverArtistLocked" (onUnlock)="chapter.coverArtistLocked = false"
|
||||
(newItemAdded)="chapter.coverArtistLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- penciller & colorist -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);chapter.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
|
||||
[(locked)]="chapter.pencillerLocked" (onUnlock)="chapter.pencillerLocked = false"
|
||||
(newItemAdded)="chapter.pencillerLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);chapter.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
|
||||
[(locked)]="chapter.coloristLocked" (onUnlock)="chapter.coloristLocked = false"
|
||||
(newItemAdded)="chapter.coloristLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- inker & letterer -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);chapter.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
|
||||
[(locked)]="chapter.inkerLocked" (onUnlock)="chapter.inkerLocked = false"
|
||||
(newItemAdded)="chapter.inkerLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);chapter.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
|
||||
[(locked)]="chapter.lettererLocked" (onUnlock)="chapter.lettererLocked = false"
|
||||
(newItemAdded)="chapter.lettererLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- translator -->
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-12 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);chapter.translatorLocked = true" [settings]="getPersonsSettings(PersonRole.Translator)"
|
||||
[(locked)]="chapter.translatorLocked" (onUnlock)="chapter.translatorLocked = false"
|
||||
(newItemAdded)="chapter.translatorLocked = 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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Cover Tab -->
|
||||
<li [ngbNavItem]="TabID.CoverImage">
|
||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-warning" role="alert">
|
||||
{{t('cover-image-description')}}
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
|
||||
[showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Info Tab -->
|
||||
<li [ngbNavItem]="TabID.Info">
|
||||
<a ngbNavLink>{{t(TabID.Info)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('pages-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{t('pages-count', {num: chapter.pages | compactNumber})}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('words-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{t('words-count', {num: chapter.wordCount | compactNumber})}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('read-time-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{chapter | readTime }}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('size-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{size | bytes}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('date-added-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('id-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{chapter.id}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@if (WebLinks.length > 0) {
|
||||
<div class="setting-section-break"></div>
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
|
||||
@for(link of WebLinks; track link) {
|
||||
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||
</a>
|
||||
}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
@for (file of chapter.files; track file.id) {
|
||||
<div>
|
||||
<span>{{file.filePath}}</span><span class="ms-2 me-2">•</span><span>{{file.bytes | bytes}}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Progress Tab -->
|
||||
<li [ngbNavItem]="TabID.Progress">
|
||||
<a ngbNavLink>{{t(TabID.Progress)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Tasks Tab -->
|
||||
<li [ngbNavItem]="TabID.Tasks">
|
||||
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@for(task of tasks; track task.action) {
|
||||
<div class="mt-3 mb-3">
|
||||
<app-setting-button [subtitle]="task.description">
|
||||
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
||||
</app-setting-button>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<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()">{{t('close')}}</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!editForm.valid" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
</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">{{t('field-locked-alt')}}</span>
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,6 @@
|
||||
.lock-active {
|
||||
> .input-group-text {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
}
|
@ -0,0 +1,534 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {
|
||||
AsyncPipe,
|
||||
DatePipe,
|
||||
DecimalPipe,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
TitleCasePipe
|
||||
} from "@angular/common";
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbInputDatepicker,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet
|
||||
} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
|
||||
import {Tag} from "../../_models/tag";
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {Person, PersonRole} from "../../_models/metadata/person";
|
||||
import {Genre} from "../../_models/metadata/genre";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {SeriesService} from "../../_services/series.service";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {UploadService} from "../../_services/upload.service";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {DownloadService} from "../../shared/_services/download.service";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
|
||||
import {forkJoin, Observable, of} from "rxjs";
|
||||
import {map} from "rxjs/operators";
|
||||
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
|
||||
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
|
||||
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
|
||||
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
|
||||
import {MangaFormat} from "../../_models/manga-format";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {TranslocoDatePipe} from "@jsverse/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {BytesPipe} from "../../_pipes/bytes.pipe";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {ReadTimePipe} from "../../_pipes/read-time.pipe";
|
||||
import {ChapterService} from "../../_services/chapter.service";
|
||||
|
||||
enum TabID {
|
||||
General = 'general-tab',
|
||||
CoverImage = 'cover-image-tab',
|
||||
Info = 'info-tab',
|
||||
People = 'people-tab',
|
||||
Tasks = 'tasks-tab',
|
||||
Progress = 'progress-tab',
|
||||
Tags = 'tags-tab'
|
||||
}
|
||||
|
||||
export interface EditChapterModalCloseResult {
|
||||
success: boolean;
|
||||
chapter: Chapter;
|
||||
coverImageUpdate: boolean;
|
||||
needsReload: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-chapter-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavLink,
|
||||
TranslocoDirective,
|
||||
AsyncPipe,
|
||||
NgbNavOutlet,
|
||||
ReactiveFormsModule,
|
||||
NgbNavItem,
|
||||
SettingItemComponent,
|
||||
NgTemplateOutlet,
|
||||
NgClass,
|
||||
TypeaheadComponent,
|
||||
EntityTitleComponent,
|
||||
TitleCasePipe,
|
||||
SettingButtonComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditChapterProgressComponent,
|
||||
NgbInputDatepicker,
|
||||
EntityInfoCardsComponent,
|
||||
CompactNumberPipe,
|
||||
IconAndTitleComponent,
|
||||
DefaultDatePipe,
|
||||
TranslocoDatePipe,
|
||||
UtcToLocalTimePipe,
|
||||
BytesPipe,
|
||||
ImageComponent,
|
||||
SafeHtmlPipe,
|
||||
DecimalPipe,
|
||||
DatePipe,
|
||||
ReadTimePipe
|
||||
],
|
||||
templateUrl: './edit-chapter-modal.component.html',
|
||||
styleUrl: './edit-chapter-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditChapterModalComponent implements OnInit {
|
||||
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly imageService = inject(ImageService);
|
||||
private readonly uploadService = inject(UploadService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly chapterService = inject(ChapterService);
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly TabID = TabID;
|
||||
protected readonly Action = Action;
|
||||
protected readonly PersonRole = PersonRole;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
@Input({required: true}) chapter!: Chapter;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
@Input({required: true}) libraryId!: number;
|
||||
@Input({required: true}) seriesId!: number;
|
||||
|
||||
activeId = TabID.General;
|
||||
editForm: FormGroup = new FormGroup({});
|
||||
selectedCover: string = '';
|
||||
coverImageReset = false;
|
||||
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
|
||||
tags: Tag[] = [];
|
||||
genres: Genre[] = [];
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
validLanguages: Array<Language> = [];
|
||||
|
||||
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList);
|
||||
/**
|
||||
* A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified
|
||||
*/
|
||||
initChapter!: Chapter;
|
||||
imageUrls: Array<string> = [];
|
||||
size: number = 0;
|
||||
|
||||
get WebLinks() {
|
||||
if (this.chapter.webLinks === '') return [];
|
||||
return this.chapter.webLinks.split(',');
|
||||
}
|
||||
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
this.initChapter = Object.assign({}, this.chapter);
|
||||
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
||||
|
||||
this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0);
|
||||
|
||||
|
||||
this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, []));
|
||||
this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)]));
|
||||
this.editForm.addControl('summary', new FormControl(this.chapter.summary, []));
|
||||
this.editForm.addControl('language', new FormControl(this.chapter.language, []));
|
||||
this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, []));
|
||||
this.editForm.addControl('ageRating', new FormControl(this.chapter.ageRating, []));
|
||||
this.editForm.addControl('releaseDate', new FormControl(this.chapter.releaseDate, []));
|
||||
|
||||
|
||||
this.editForm.addControl('genres', new FormControl(this.chapter.genres, []));
|
||||
this.editForm.addControl('tags', new FormControl(this.chapter.tags, []));
|
||||
|
||||
|
||||
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
|
||||
this.editForm.addControl('coverImageLocked', new FormControl(this.chapter.coverImageLocked, []));
|
||||
|
||||
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
|
||||
this.validLanguages = validLanguages;
|
||||
this.setupLanguageTypeahead();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings = ratings;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.editForm.get('titleName')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
this.chapter.titleNameLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.editForm.get('sortOrder')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
this.chapter.sortOrderLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.editForm.get('isbn')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
this.chapter.isbnLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.editForm.get('ageRating')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
this.chapter.ageRatingLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.editForm.get('summary')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
this.chapter.summaryLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.editForm.get('releaseDate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
this.chapter.releaseDateLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.setupTypeaheads();
|
||||
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.dismiss();
|
||||
}
|
||||
|
||||
save() {
|
||||
const model = this.editForm.value;
|
||||
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
|
||||
|
||||
this.chapter.releaseDate = model.releaseDate;
|
||||
|
||||
|
||||
const apis = [
|
||||
this.chapterService.updateChapter(this.chapter)
|
||||
];
|
||||
|
||||
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
|
||||
const needsReload = this.editForm.get('titleName')?.dirty || this.editForm.get('sortOrder')?.dirty;
|
||||
|
||||
|
||||
if (selectedIndex > 0 || this.coverImageReset) {
|
||||
apis.push(this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover, !this.coverImageReset));
|
||||
}
|
||||
|
||||
forkJoin(apis).subscribe(results => {
|
||||
this.modal.close({success: true, chapter: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: needsReload, isDeleted: false} as EditChapterModalCloseResult);
|
||||
});
|
||||
}
|
||||
|
||||
unlock(b: any, field: string) {
|
||||
if (b) {
|
||||
b[field] = !b[field];
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
async runTask(action: ActionItem<Chapter>) {
|
||||
switch (action.action) {
|
||||
|
||||
case Action.MarkAsRead:
|
||||
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, this.chapter, (p) => {
|
||||
this.chapter.pagesRead = p.pagesRead;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, this.chapter, (p) => {
|
||||
this.chapter.pagesRead = 0;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
await this.actionService.deleteChapter(this.chapter.id, (b) => {
|
||||
if (!b) return;
|
||||
this.modal.close({success: b, chapter: this.chapter, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditChapterModalCloseResult);
|
||||
});
|
||||
break;
|
||||
case Action.Download:
|
||||
this.downloadService.download('chapter', this.chapter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setupTypeaheads() {
|
||||
forkJoin([
|
||||
this.setupTagSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
this.setupLanguageTypeahead()
|
||||
]).subscribe(results => {
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
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.selectionCompareFn = (a: Tag, b: Tag) => {
|
||||
return a.title.toLowerCase() == b.title.toLowerCase();
|
||||
}
|
||||
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
|
||||
if (this.chapter.tags) {
|
||||
this.tagsSettings.savedData = this.chapter.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.compareFnForAdd = (options: Genre[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.genreSettings.selectionCompareFn = (a: Genre, b: Genre) => {
|
||||
return a.title.toLowerCase() == b.title.toLowerCase();
|
||||
}
|
||||
|
||||
this.genreSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title };
|
||||
});
|
||||
|
||||
if (this.chapter.genres) {
|
||||
this.genreSettings.savedData = this.chapter.genres;
|
||||
}
|
||||
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.compareFnForAdd = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.chapter.language);
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
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 presetIds = presetField.map(p => p.id);
|
||||
personSettings.savedData = people.filter(person => presetIds.includes(person.id));
|
||||
this.peopleSettings[role] = personSettings;
|
||||
this.metadataService.updatePerson(this.chapter, personSettings.savedData as Person[], role);
|
||||
this.cdRef.markForCheck();
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
}
|
||||
}
|
||||
|
||||
setupPersonTypeahead() {
|
||||
this.peopleSettings = {};
|
||||
|
||||
return forkJoin([
|
||||
this.updateFromPreset('writer', this.chapter.writers, PersonRole.Writer),
|
||||
this.updateFromPreset('character', this.chapter.characters, PersonRole.Character),
|
||||
this.updateFromPreset('colorist', this.chapter.colorists, PersonRole.Colorist),
|
||||
this.updateFromPreset('cover-artist', this.chapter.coverArtists, PersonRole.CoverArtist),
|
||||
this.updateFromPreset('editor', this.chapter.editors, PersonRole.Editor),
|
||||
this.updateFromPreset('inker', this.chapter.inkers, PersonRole.Inker),
|
||||
this.updateFromPreset('letterer', this.chapter.letterers, PersonRole.Letterer),
|
||||
this.updateFromPreset('penciller', this.chapter.pencillers, PersonRole.Penciller),
|
||||
this.updateFromPreset('publisher', this.chapter.publishers, PersonRole.Publisher),
|
||||
this.updateFromPreset('imprint', this.chapter.imprints, PersonRole.Imprint),
|
||||
this.updateFromPreset('translator', this.chapter.translators, PersonRole.Translator),
|
||||
this.updateFromPreset('teams', this.chapter.teams, PersonRole.Team),
|
||||
this.updateFromPreset('locations', this.chapter.locations, PersonRole.Location),
|
||||
]).pipe(map(results => {
|
||||
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.compareFnForAdd = (options: Person[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.name, filter));
|
||||
}
|
||||
|
||||
personSettings.selectionCompareFn = (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;
|
||||
}
|
||||
|
||||
updateTags(tags: Tag[]) {
|
||||
this.tags = tags;
|
||||
this.chapter.tags = tags;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateGenres(genres: Genre[]) {
|
||||
this.genres = genres;
|
||||
this.chapter.genres = genres;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updatePerson(persons: Person[], role: PersonRole) {
|
||||
this.metadataService.updatePerson(this.chapter, persons, role);
|
||||
this.chapter.locationLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateLanguage(language: Array<Language>) {
|
||||
if (language.length === 0) {
|
||||
this.chapter.language = '';
|
||||
return;
|
||||
}
|
||||
this.chapter.language = language[0].isoCode;
|
||||
this.chapter.languageLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.editForm.patchValue({
|
||||
coverImageIndex: index
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.coverImageReset = true;
|
||||
this.editForm.patchValue({
|
||||
coverImageLocked: false
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
}
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
<ng-container *transloco="let t; read: 'edit-volume-modal'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="volume" [prioritizeTitleName]="false"></app-entity-title></h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal" [ngClass]="{'d-flex': utilityService.getActiveBreakpoint() !== Breakpoint.Mobile}">
|
||||
<form [formGroup]="editForm">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
|
||||
<!-- Cover Tab -->
|
||||
<li [ngbNavItem]="TabID.CoverImage">
|
||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-warning" role="alert">
|
||||
{{t('cover-image-description')}}
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
|
||||
[showReset]="volume.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Info Tab -->
|
||||
<li [ngbNavItem]="TabID.Info">
|
||||
<a ngbNavLink>{{t(TabID.Info)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('pages-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{t('pages-count', {num: volume.pages | compactNumber})}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="col-lg-6 col-md-12 pe-2">-->
|
||||
<!-- <div class="mb-3">-->
|
||||
<!-- <app-setting-item [title]="t('words-label')" [toggleOnViewClick]="false" [showEdit]="false">-->
|
||||
<!-- <ng-template #view>-->
|
||||
<!-- {{t('words-count', {num: volume.wordCount | compactNumber})}}-->
|
||||
<!-- </ng-template>-->
|
||||
<!-- </app-setting-item>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('read-time-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{volume | readTime }}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('size-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{size | bytes}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('date-added-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{volume.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<app-setting-item [title]="t('id-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
{{volume.id}}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
@for (file of files; track file.id) {
|
||||
<div>
|
||||
<span>{{file.filePath}}</span><span class="ms-2 me-2">•</span><span>{{file.bytes | bytes}}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Progress Tab -->
|
||||
<li [ngbNavItem]="TabID.Progress">
|
||||
<a ngbNavLink>{{t(TabID.Progress)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@for(chapter of volume.chapters; track chapter.id) {
|
||||
<h6><app-entity-title [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title></h6>
|
||||
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
|
||||
<div class="setting-section-break"></div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<!-- Tasks Tab -->
|
||||
<li [ngbNavItem]="TabID.Tasks">
|
||||
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@for(task of tasks; track task.action) {
|
||||
<div class="mt-3 mb-3">
|
||||
<app-setting-button [subtitle]="task.description">
|
||||
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
||||
</app-setting-button>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<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()">{{t('close')}}</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!editForm.valid" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,231 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbInputDatepicker,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet
|
||||
} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
|
||||
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
|
||||
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
|
||||
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
|
||||
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
|
||||
import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component";
|
||||
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
|
||||
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {TranslocoDatePipe} from "@jsverse/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {BytesPipe} from "../../_pipes/bytes.pipe";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {ReadTimePipe} from "../../_pipes/read-time.pipe";
|
||||
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
|
||||
import {Volume} from "../../_models/volume";
|
||||
import {SeriesService} from "../../_services/series.service";
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {UploadService} from "../../_services/upload.service";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {DownloadService} from "../../shared/_services/download.service";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
|
||||
import {Tag} from "../../_models/tag";
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {Person, PersonRole} from "../../_models/metadata/person";
|
||||
import {Genre} from "../../_models/metadata/genre";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {forkJoin, Observable, of} from "rxjs";
|
||||
import {map} from "rxjs/operators";
|
||||
import {EditChapterModalCloseResult} from "../edit-chapter-modal/edit-chapter-modal.component";
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import {MangaFile} from "../../_models/manga-file";
|
||||
import {VolumeService} from "../../_services/volume.service";
|
||||
|
||||
enum TabID {
|
||||
General = 'general-tab',
|
||||
CoverImage = 'cover-image-tab',
|
||||
Info = 'info-tab',
|
||||
Tasks = 'tasks-tab',
|
||||
Progress = 'progress-tab',
|
||||
}
|
||||
|
||||
export interface EditVolumeModalCloseResult {
|
||||
success: boolean;
|
||||
volume: Volume;
|
||||
coverImageUpdate: boolean;
|
||||
needsReload: boolean;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-volume-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavLink,
|
||||
TranslocoDirective,
|
||||
AsyncPipe,
|
||||
NgbNavOutlet,
|
||||
ReactiveFormsModule,
|
||||
NgbNavItem,
|
||||
SettingItemComponent,
|
||||
NgTemplateOutlet,
|
||||
NgClass,
|
||||
TypeaheadComponent,
|
||||
EntityTitleComponent,
|
||||
TitleCasePipe,
|
||||
SettingButtonComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditChapterProgressComponent,
|
||||
NgbInputDatepicker,
|
||||
EntityInfoCardsComponent,
|
||||
CompactNumberPipe,
|
||||
IconAndTitleComponent,
|
||||
DefaultDatePipe,
|
||||
TranslocoDatePipe,
|
||||
UtcToLocalTimePipe,
|
||||
BytesPipe,
|
||||
ImageComponent,
|
||||
SafeHtmlPipe,
|
||||
DecimalPipe,
|
||||
DatePipe,
|
||||
ReadTimePipe
|
||||
],
|
||||
templateUrl: './edit-volume-modal.component.html',
|
||||
styleUrl: './edit-volume-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditVolumeModalComponent implements OnInit {
|
||||
public readonly modal = inject(NgbActiveModal);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly imageService = inject(ImageService);
|
||||
private readonly uploadService = inject(UploadService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
public readonly accountService = inject(AccountService);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly volumeService = inject(VolumeService);
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly TabID = TabID;
|
||||
protected readonly Action = Action;
|
||||
protected readonly PersonRole = PersonRole;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
@Input({required: true}) volume!: Volume;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
@Input({required: true}) libraryId!: number;
|
||||
@Input({required: true}) seriesId!: number;
|
||||
|
||||
activeId = TabID.CoverImage;
|
||||
editForm: FormGroup = new FormGroup({});
|
||||
selectedCover: string = '';
|
||||
coverImageReset = false;
|
||||
|
||||
|
||||
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList);
|
||||
/**
|
||||
* A copy of the chapter from init. This is used to compare values for name fields to see if lock was modified
|
||||
*/
|
||||
initVolume!: Volume;
|
||||
imageUrls: Array<string> = [];
|
||||
size: number = 0;
|
||||
files: Array<MangaFile> = [];
|
||||
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
this.initVolume = Object.assign({}, this.volume);
|
||||
this.imageUrls.push(this.imageService.getVolumeCoverImage(this.volume.id));
|
||||
|
||||
this.files = this.volume.chapters.flatMap(c => c.files);
|
||||
this.size = this.files.reduce((sum, v) => sum + v.bytes, 0);
|
||||
|
||||
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
|
||||
this.editForm.addControl('coverImageLocked', new FormControl(this.volume.coverImageLocked, []));
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.dismiss();
|
||||
}
|
||||
|
||||
save() {
|
||||
const model = this.editForm.value;
|
||||
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
|
||||
|
||||
const apis = [];
|
||||
|
||||
|
||||
if (selectedIndex > 0 || this.coverImageReset) {
|
||||
apis.push(this.uploadService.updateVolumeCoverImage(model.id, this.selectedCover, !this.coverImageReset));
|
||||
}
|
||||
|
||||
forkJoin(apis).subscribe(results => {
|
||||
this.modal.close({success: true, volume: model, coverImageUpdate: selectedIndex > 0 || this.coverImageReset, needsReload: false, isDeleted: false} as EditVolumeModalCloseResult);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async runTask(action: ActionItem<Volume>) {
|
||||
switch (action.action) {
|
||||
case Action.MarkAsRead:
|
||||
this.actionService.markVolumeAsRead(this.seriesId, this.volume, (p) => {
|
||||
this.volume.pagesRead = p.pagesRead;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
this.actionService.markVolumeAsUnread(this.seriesId, this.volume, (p) => {
|
||||
this.volume.pagesRead = 0;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
await this.actionService.deleteVolume(this.volume.id, (b) => {
|
||||
if (!b) return;
|
||||
this.modal.close({success: b, volume: this.volume, coverImageUpdate: false, needsReload: true, isDeleted: b} as EditVolumeModalCloseResult);
|
||||
});
|
||||
break;
|
||||
case Action.Download:
|
||||
this.downloadService.download('volume', this.volume);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.editForm.patchValue({
|
||||
coverImageIndex: index
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.coverImageReset = true;
|
||||
this.editForm.patchValue({
|
||||
coverImageLocked: false
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
@ -1,36 +1,31 @@
|
||||
<ng-container *transloco="let t; read:'review-card'">
|
||||
<div class="card review-card clickable mb-3" (click)="showModal()">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2 d-none d-md-block">
|
||||
<i class="img-fluid rounded-start fa-solid fa-circle-user profile-image" aria-hidden="true"></i>
|
||||
<div class="col-md-2 d-none d-md-block p-2">
|
||||
@if (isMyReview) {
|
||||
<div class="my-review">
|
||||
<i class="fa-solid fa-star" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
<span class="visually-hidden">{{t('your-review')}}</span>
|
||||
</div>
|
||||
<i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
<img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="40" height="40" alt="">
|
||||
} @else {
|
||||
<img class="me-2" [ngSrc]="review.provider | providerImage" width="40" height="40" alt="">
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="card-body">
|
||||
<div class="card-body p-2">
|
||||
<!--
|
||||
<h6 class="card-title">
|
||||
{{review.isExternal ? t('external-review') : t('local-review')}}
|
||||
</h6>
|
||||
</h6>-->
|
||||
<p class="card-text no-images">
|
||||
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="100" [showToggle]="false"></app-read-more>
|
||||
<app-read-more [text]="(review.isExternal ? review.bodyJustText : review.body) || ''" [maxLength]="150" [showToggle]="false"></app-read-more>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer bg-transparent text-muted">
|
||||
<div class="card-footer bg-transparent text-muted p-2">
|
||||
<div>
|
||||
@if (isMyReview) {
|
||||
<i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
|
||||
<img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="20" height="20" alt="">
|
||||
{{review.username}}
|
||||
} @else {
|
||||
<img class="me-2" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
|
||||
}
|
||||
|
||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||
</div>
|
||||
@if (review.isExternal){
|
||||
|
@ -1,7 +1,7 @@
|
||||
.review-card {
|
||||
max-width: 320px;
|
||||
max-height: 160px;
|
||||
height: 160px;
|
||||
max-height: 130px;
|
||||
height: 130px;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
@ -33,8 +33,6 @@
|
||||
}
|
||||
|
||||
.card-text.no-images {
|
||||
min-height: 63px;
|
||||
max-height: 63px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -49,10 +47,16 @@
|
||||
max-width: 319px;
|
||||
justify-content: space-between;
|
||||
margin: 0 auto;
|
||||
padding: .5rem 0;
|
||||
|
||||
& > * {
|
||||
margin: 0 5px;
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
min-height: 93.5px;
|
||||
max-height: 93.5px;
|
||||
}
|
@ -51,9 +51,9 @@
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="externalSeries.staff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<div class="card mb-3" style="max-width: 180px;">
|
||||
<div class="card mb-3">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson">
|
||||
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
|
||||
</ng-container>
|
||||
@ -61,7 +61,7 @@
|
||||
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="col-md-9">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{item.name}}</h6>
|
||||
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
|
||||
@ -109,7 +109,7 @@
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<div class="card mb-3" style="max-width: 180px;">
|
||||
<div class="card mb-3">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4">
|
||||
<i class="fa fa-user-circle align-self-center" style="font-size: 28px; margin-top: 24px; margin-left: 24px" aria-hidden="true"></i>
|
||||
|
@ -0,0 +1 @@
|
||||
<app-image [imageUrl]="imageUrl" height="32px" width="32px" ngbTooltip="{{rating | ageRating}}"></app-image>
|
@ -0,0 +1,82 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
|
||||
const basePath = './assets/images/ratings/';
|
||||
|
||||
@Component({
|
||||
selector: 'app-age-rating-image',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ImageComponent,
|
||||
NgbTooltip,
|
||||
AgeRatingPipe,
|
||||
AsyncPipe
|
||||
],
|
||||
templateUrl: './age-rating-image.component.html',
|
||||
styleUrl: './age-rating-image.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AgeRatingImageComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
@Input({required: true}) rating: AgeRating = AgeRating.Unknown;
|
||||
protected readonly AgeRating = AgeRating;
|
||||
|
||||
imageUrl: string = 'unknown-rating.png';
|
||||
|
||||
ngOnInit() {
|
||||
switch (this.rating) {
|
||||
case AgeRating.Unknown:
|
||||
this.imageUrl = basePath + 'unknown-rating.png';
|
||||
break;
|
||||
case AgeRating.RatingPending:
|
||||
this.imageUrl = basePath + 'rating-pending-rating.png';
|
||||
break;
|
||||
case AgeRating.EarlyChildhood:
|
||||
this.imageUrl = basePath + 'early-childhood-rating.png';
|
||||
break;
|
||||
case AgeRating.Everyone:
|
||||
this.imageUrl = basePath + 'everyone-rating.png';
|
||||
break;
|
||||
case AgeRating.G:
|
||||
this.imageUrl = basePath + 'g-rating.png';
|
||||
break;
|
||||
case AgeRating.Everyone10Plus:
|
||||
this.imageUrl = basePath + 'everyone-10+-rating.png';
|
||||
break;
|
||||
case AgeRating.PG:
|
||||
this.imageUrl = basePath + 'pg-rating.png';
|
||||
break;
|
||||
case AgeRating.KidsToAdults:
|
||||
this.imageUrl = basePath + 'kids-to-adults-rating.png';
|
||||
break;
|
||||
case AgeRating.Teen:
|
||||
this.imageUrl = basePath + 'teen-rating.png';
|
||||
break;
|
||||
case AgeRating.Mature15Plus:
|
||||
this.imageUrl = basePath + 'ma15+-rating.png';
|
||||
break;
|
||||
case AgeRating.Mature17Plus:
|
||||
this.imageUrl = basePath + 'mature-17+-rating.png';
|
||||
break;
|
||||
case AgeRating.Mature:
|
||||
this.imageUrl = basePath + 'm-rating.png';
|
||||
break;
|
||||
case AgeRating.R18Plus:
|
||||
this.imageUrl = basePath + 'r18+-rating.png';
|
||||
break;
|
||||
case AgeRating.AdultsOnly:
|
||||
this.imageUrl = basePath + 'adults-only-18+-rating.png';
|
||||
break;
|
||||
case AgeRating.X18Plus:
|
||||
this.imageUrl = basePath + 'x18+-rating.png';
|
||||
break;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
<ng-container *transloco="let t; read: 'related-tab'">
|
||||
@if (readingLists.length > 0) {
|
||||
<app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<app-card-item [title]="item.title" [entity]="item"
|
||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||
(clicked)="openReadingList(item)"></app-card-item>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
</ng-container>
|
@ -0,0 +1,30 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
|
||||
import {ReadingList} from "../../_models/reading-list";
|
||||
import {CardItemComponent} from "../../cards/card-item/card-item.component";
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-related-tab',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardItemComponent,
|
||||
CarouselReelComponent,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './related-tab.component.html',
|
||||
styleUrl: './related-tab.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RelatedTabComponent {
|
||||
|
||||
protected readonly imageService = inject(ImageService);
|
||||
|
||||
@Input() readingLists: Array<ReadingList> = [];
|
||||
|
||||
openReadingList(readingList: ReadingList) {
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -2,79 +2,45 @@
|
||||
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}} <a [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">FAQ</a></p>
|
||||
<p>{{t('kavita+-requirement')}} <a [routerLink]="'/announcements'">{{t('kavita+-releases')}}</a></p>
|
||||
|
||||
<div class="card mt-2">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="row mb-2">
|
||||
<div class="col-8">
|
||||
<h4 id="license-key-header">{{t('title')}}</h4>
|
||||
</div>
|
||||
<div class="col-4 text-end">
|
||||
@if (hasLicense) {
|
||||
@if (hasValidLicense) {
|
||||
<a class="btn btn-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
} @else {
|
||||
<a class="btn btn-primary btn-sm me-1"
|
||||
[ngbTooltip]="t('invalid-license-tooltip')"
|
||||
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
|
||||
>{{t('renew')}}</a>
|
||||
}
|
||||
<button class="btn btn-secondary btn-sm me-1" style="width: 58px" (click)="validateLicense()">
|
||||
@if (isChecking) {
|
||||
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
|
||||
} @else {
|
||||
<span>{{t('check')}}</span>
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" style="width: 62px" (click)="toggleViewMode()">
|
||||
@if (isViewMode) {
|
||||
<span>{{t('edit')}}</span>
|
||||
} @else {
|
||||
<span>{{t('cancel')}}</span>
|
||||
}
|
||||
</button>
|
||||
<form [formGroup]="formGroup">
|
||||
<div class="mt-2">
|
||||
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)">
|
||||
<ng-template #titleExtra>
|
||||
<button class="btn btn-icon btn-sm" style="width: 58px" (click)="validateLicense()">
|
||||
@if (isChecking) {
|
||||
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading>
|
||||
} @else {
|
||||
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
|
||||
<span>
|
||||
<i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template #view>
|
||||
@if (hasLicense) {
|
||||
<span class="me-1">*********</span>
|
||||
|
||||
@if (isViewMode) {
|
||||
<div class="row">
|
||||
<span class="col-12">
|
||||
@if (hasLicense) {
|
||||
<span class="me-1">*********</span>
|
||||
@if (isChecking) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
@if (isChecking) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
} @else {
|
||||
@if (hasValidLicense) {
|
||||
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
|
||||
<span class="visually-hidden">{{t('license-valid')}}</span>
|
||||
</i>
|
||||
} @else {
|
||||
@if (hasValidLicense) {
|
||||
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
|
||||
<span class="visually-hidden">{{t('license-valid')}}</span>
|
||||
</i>
|
||||
}
|
||||
|
||||
@if (!hasValidLicense) {
|
||||
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
|
||||
<span class="visually-hidden">{{t('license-not-valid')}}</span>
|
||||
</i>
|
||||
}
|
||||
<i class="error fa-solid fa-exclamation-circle ms-1" [ngbTooltip]="t('license-not-valid')">
|
||||
<span class="visually-hidden">{{t('license-not-valid')}}</span>
|
||||
</i>
|
||||
}
|
||||
}
|
||||
@else {
|
||||
{{t('no-license-key')}}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
{{t('no-license-key')}}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
|
||||
<form [formGroup]="formGroup">
|
||||
<p>{{t('activate-description')}}</p>
|
||||
<ng-template #edit>
|
||||
<div class="form-group mb-3">
|
||||
<label for="license-key">{{t('activate-license-label')}}</label>
|
||||
<input id="license-key" type="text" class="form-control" formControlName="licenseKey" autocomplete="off"/>
|
||||
@ -98,29 +64,46 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
|
||||
(click)="deleteLicense()">
|
||||
{{t('activate-delete')}}
|
||||
</button>
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
|
||||
[ngbTooltip]="t('activate-reset-tooltip')"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
|
||||
{{t('activate-reset')}}
|
||||
</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
|
||||
@if (!isSaving) {
|
||||
<span>{{t('activate-save')}}</span>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
|
||||
(click)="deleteLicense()">
|
||||
{{t('activate-delete')}}
|
||||
</button>
|
||||
<button type="button" class="flex-fill btn btn-danger me-1" aria-describedby="license-key-header"
|
||||
[ngbTooltip]="t('activate-reset-tooltip')"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="resetLicense()">
|
||||
{{t('activate-reset')}}
|
||||
</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
|
||||
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
|
||||
@if (!isSaving) {
|
||||
<span>{{t('activate-save')}}</span>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
|
||||
<ng-template #titleActions>
|
||||
@if (hasLicense) {
|
||||
@if (hasValidLicense) {
|
||||
<a class="btn btn-primary-outline btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
|
||||
} @else {
|
||||
<a class="btn btn-primary-outline btn-sm me-1"
|
||||
[ngbTooltip]="t('invalid-license-tooltip')"
|
||||
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
|
||||
>{{t('renew')}}</a>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
|
||||
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</ng-container>
|
||||
|
@ -6,17 +6,15 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {ScrobblingService} from "../../_services/scrobbling.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
import { LoadingComponent } from '../../shared/loading/loading.component';
|
||||
import { NgbTooltip, NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgIf } from '@angular/common';
|
||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {catchError} from "rxjs";
|
||||
import {WikiLink} from "../../_models/wiki";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-license',
|
||||
@ -24,7 +22,7 @@ import {RouterLink} from "@angular/router";
|
||||
styleUrls: ['./license.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgbTooltip, LoadingComponent, NgbCollapse, ReactiveFormsModule, TranslocoDirective, RouterLink]
|
||||
imports: [NgbTooltip, LoadingComponent, ReactiveFormsModule, TranslocoDirective, RouterLink, SettingItemComponent]
|
||||
})
|
||||
export class LicenseComponent implements OnInit {
|
||||
|
||||
@ -143,4 +141,9 @@ export class LicenseComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
updateEditMode(mode: boolean) {
|
||||
this.isViewMode = mode;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +0,0 @@
|
||||
.alert-warning {
|
||||
--bs-alert-color: #fff3cd;
|
||||
--bs-alert-bg: transparent;
|
||||
--bs-alert-border-color: #ffecb5;
|
||||
font-size: 14px;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.alert-warning {
|
||||
--bs-alert-color: #fff3cd;
|
||||
--bs-alert-bg: transparent;
|
||||
--bs-alert-border-color: #ffecb5;
|
||||
font-size: 14px;
|
||||
}
|
@ -1,10 +1,3 @@
|
||||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
--bs-alert-color: #fff3cd;
|
||||
--bs-alert-bg: transparent;
|
||||
--bs-alert-border-color: #ffecb5;
|
||||
font-size: 14px;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.alert-warning {
|
||||
--bs-alert-color: #fff3cd;
|
||||
--bs-alert-bg: transparent;
|
||||
--bs-alert-border-color: #ffecb5;
|
||||
font-size: 14px;
|
||||
}
|
@ -16,7 +16,7 @@
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
|
||||
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
|
@ -66,6 +66,11 @@ const routes: Routes = [
|
||||
pathMatch: 'full',
|
||||
loadComponent: () => import('./chapter-detail/chapter-detail.component').then(c => c.ChapterDetailComponent)
|
||||
},
|
||||
{
|
||||
path: ':libraryId/series/:seriesId/volume/:volumeId',
|
||||
pathMatch: 'full',
|
||||
loadComponent: () => import('./volume-detail/volume-detail.component').then(c => c.VolumeDetailComponent)
|
||||
},
|
||||
{
|
||||
path: ':libraryId/series/:seriesId/manga',
|
||||
loadChildren: () => import('./_routes/manga-reader.router.module').then(m => m.routes)
|
||||
|
@ -112,7 +112,7 @@
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6">
|
||||
<div>{{t('last-sync-title')}}</div>
|
||||
<div>{{tag.lastSyncUtc | date:'shortDate' | defaultDate}}</div>
|
||||
<div>{{tag.lastSyncUtc | utcToLocalTime | date:'shortDate' | defaultDate}}</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div>{{t('source-url-title')}}</div>
|
||||
|
@ -37,6 +37,7 @@ import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
|
||||
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
|
||||
import {TagBadgeComponent} from "../../../shared/tag-badge/tag-badge.component";
|
||||
import {SelectionModel} from "../../../typeahead/_models/selection-model";
|
||||
import {UtcToLocalTimePipe} from "../../../_pipes/utc-to-local-time.pipe";
|
||||
|
||||
|
||||
enum TabID {
|
||||
@ -50,7 +51,7 @@ enum TabID {
|
||||
selector: 'app-edit-collection-tags',
|
||||
standalone: true,
|
||||
imports: [NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ReactiveFormsModule, FormsModule, NgbPagination,
|
||||
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe, DatePipe, DefaultDatePipe, ReadMoreComponent, SafeHtmlPipe, SafeUrlPipe, MangaFormatPipe, NgIf, SentenceCasePipe, TagBadgeComponent, DecimalPipe],
|
||||
CoverImageChooserComponent, NgbNavOutlet, NgbTooltip, TranslocoDirective, NgTemplateOutlet, FilterPipe, DatePipe, DefaultDatePipe, ReadMoreComponent, SafeHtmlPipe, SafeUrlPipe, MangaFormatPipe, NgIf, SentenceCasePipe, TagBadgeComponent, DecimalPipe, UtcToLocalTimePipe],
|
||||
templateUrl: './edit-collection-tags.component.html',
|
||||
styleUrls: ['./edit-collection-tags.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -1,8 +1,8 @@
|
||||
<ng-container *transloco="let t; read: 'edit-series-modal'">
|
||||
<div class="modal-container" *ngIf="series !== undefined">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">
|
||||
{{t('title', {seriesName: this.series.name})}}</h4>
|
||||
<h5 class="modal-title">
|
||||
{{t('title', {seriesName: this.series.name})}}</h5>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
@ -14,53 +14,73 @@
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">{{t('name-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="name" class="form-control" formControlName="name" type="text" readonly
|
||||
[class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('required-field')}}
|
||||
<app-setting-item [title]="t('name-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<div class="input-group">
|
||||
@if (editSeriesForm.get('name'); as formControl) {
|
||||
<input id="name" class="form-control" formControlName="name" type="text" readonly
|
||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||
@if (formControl.errors) {
|
||||
@if (formControl.errors.required) {
|
||||
<div class="invalid-feedback">{{t('required-field')}}</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">{{t('sort-name-label')}}</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" [class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
@if (editSeriesForm.get('sortName'); as formControl) {
|
||||
<app-setting-item [title]="t('sort-name-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<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" [class.is-invalid]="formControl.invalid && formControl.touched">
|
||||
@if (formControl.errors) {
|
||||
@if (formControl.errors.required) {
|
||||
<div class="invalid-feedback">{{t('required-field')}}</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="localized-name" class="form-label">{{t('localized-name-label')}}</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>
|
||||
<app-setting-item [title]="t('localized-name-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
@if (editSeriesForm.get('localizedName'); as formControl) {
|
||||
<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>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0" *ngIf="metadata">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="summary" class="form-label">{{t('summary-label')}}</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>
|
||||
@if (metadata) {
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<app-setting-item [title]="t('summary-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
@ -72,33 +92,39 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="language" class="form-label">{{t('language-label')}}</label>
|
||||
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
|
||||
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
|
||||
(newItemAdded)="metadata.languageLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updateLanguage($event);metadata.languageLocked = true;" [settings]="languageSettings"
|
||||
[(locked)]="metadata.languageLocked" (onUnlock)="metadata.languageLocked = false"
|
||||
(newItemAdded)="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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}} ({{item.isoCode}})
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-12">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="release-year" class="form-label">{{t('release-year-label')}}</label>
|
||||
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
|
||||
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear"
|
||||
maxlength="4" minlength="4"
|
||||
[class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.pattern">
|
||||
This must be a valid year greater than 1000 and 4 characters long
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
<app-setting-item [title]="t('release-year-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<div class="input-group {{metadata.releaseYearLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'releaseYearLocked' }"></ng-container>
|
||||
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear"
|
||||
maxlength="4" minlength="4"
|
||||
[class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.pattern">
|
||||
This must be a valid year greater than 1000 and 4 characters long
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -106,17 +132,20 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="genres" class="form-label">{{t('genres-label')}}</label>
|
||||
<app-typeahead (selectedData)="updateGenres($event);metadata.genresLocked = true" [settings]="genreSettings"
|
||||
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
|
||||
(newItemAdded)="metadata.genresLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updateGenres($event);metadata.genresLocked = true" [settings]="genreSettings"
|
||||
[(locked)]="metadata.genresLocked" (onUnlock)="metadata.genresLocked = false"
|
||||
(newItemAdded)="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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -124,45 +153,53 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="tags" class="form-label">{{t('tags-label')}}</label>
|
||||
<app-typeahead (selectedData)="updateTags($event);metadata.tagsLocked = true" [settings]="tagsSettings"
|
||||
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
|
||||
(newItemAdded)="metadata.tagsLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updateTags($event);metadata.tagsLocked = true" [settings]="tagsSettings"
|
||||
[(locked)]="metadata.tagsLocked" (onUnlock)="metadata.tagsLocked = false"
|
||||
(newItemAdded)="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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<!-- <div class="col-lg-4 col-md-12 pe-2">-->
|
||||
<!-- -->
|
||||
<!-- </div>-->
|
||||
<div class="col-lg-6 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="age-rating" class="form-label">{{t('age-rating-label')}}</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>
|
||||
<app-setting-item [title]="t('age-rating-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<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">
|
||||
@for (opt of ageRatings; track opt.title) {
|
||||
<option [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="publication-status" class="form-label">{{t('publication-status-label')}}</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>
|
||||
<app-setting-item [title]="t('publication-status-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<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>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -174,113 +211,134 @@
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="writer" class="form-label">{{t('writer-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);metadata.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
|
||||
[(locked)]="metadata.writerLocked" (onUnlock)="metadata.writerLocked = false"
|
||||
(newItemAdded)="metadata.writerLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);metadata.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
|
||||
[(locked)]="metadata.writerLocked" (onUnlock)="metadata.writerLocked = false"
|
||||
(newItemAdded)="metadata.writerLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="cover-artist" class="form-label">{{t('cover-artist-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);metadata.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
||||
[(locked)]="metadata.coverArtistLocked" (onUnlock)="metadata.coverArtistLocked = false"
|
||||
(newItemAdded)="metadata.coverArtistLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);metadata.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
||||
[(locked)]="metadata.coverArtistLocked" (onUnlock)="metadata.coverArtistLocked = false"
|
||||
(newItemAdded)="metadata.coverArtistLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="publisher" class="form-label">{{t('publisher-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
||||
[(locked)]="metadata.publisherLocked" (onUnlock)="metadata.publisherLocked = false"
|
||||
(newItemAdded)="metadata.publisherLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
||||
[(locked)]="metadata.publisherLocked" (onUnlock)="metadata.publisherLocked = false"
|
||||
(newItemAdded)="metadata.publisherLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="imprint" class="form-label">{{t('imprint-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
|
||||
[(locked)]="metadata.imprintLocked" (onUnlock)="metadata.imprintLocked = false"
|
||||
(newItemAdded)="metadata.imprintLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
|
||||
[(locked)]="metadata.imprintLocked" (onUnlock)="metadata.imprintLocked = false"
|
||||
(newItemAdded)="metadata.imprintLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="penciller" class="form-label">{{t('penciller-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);metadata.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
|
||||
[(locked)]="metadata.pencillerLocked" (onUnlock)="metadata.pencillerLocked = false"
|
||||
(newItemAdded)="metadata.pencillerLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);metadata.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
|
||||
[(locked)]="metadata.pencillerLocked" (onUnlock)="metadata.pencillerLocked = false"
|
||||
(newItemAdded)="metadata.pencillerLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="letterer" class="form-label">{{t('letterer-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);metadata.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
|
||||
[(locked)]="metadata.lettererLocked" (onUnlock)="metadata.lettererLocked = false"
|
||||
(newItemAdded)="metadata.lettererLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);metadata.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
|
||||
[(locked)]="metadata.lettererLocked" (onUnlock)="metadata.lettererLocked = false"
|
||||
(newItemAdded)="metadata.lettererLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="inker" class="form-label">{{t('inker-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);metadata.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
|
||||
[(locked)]="metadata.inkerLocked" (onUnlock)="metadata.inkerLocked = false"
|
||||
(newItemAdded)="metadata.inkerLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);metadata.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
|
||||
[(locked)]="metadata.inkerLocked" (onUnlock)="metadata.inkerLocked = false"
|
||||
(newItemAdded)="metadata.inkerLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -288,96 +346,114 @@
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="editor" class="form-label">{{t('editor-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor);metadata.editorLocked = true" [settings]="getPersonsSettings(PersonRole.Editor)"
|
||||
[(locked)]="metadata.editorLocked" (onUnlock)="metadata.editorLocked = false"
|
||||
(newItemAdded)="metadata.editorLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('editor-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Editor);metadata.editorLocked = true" [settings]="getPersonsSettings(PersonRole.Editor)"
|
||||
[(locked)]="metadata.editorLocked" (onUnlock)="metadata.editorLocked = false"
|
||||
(newItemAdded)="metadata.editorLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="colorist" class="form-label">{{t('colorist-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);metadata.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
|
||||
[(locked)]="metadata.coloristLocked" (onUnlock)="metadata.coloristLocked = false"
|
||||
(newItemAdded)="metadata.coloristLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);metadata.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
|
||||
[(locked)]="metadata.coloristLocked" (onUnlock)="metadata.coloristLocked = false"
|
||||
(newItemAdded)="metadata.coloristLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="translator" class="form-label">{{t('translator-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);metadata.translatorLocked = true;" [settings]="getPersonsSettings(PersonRole.Translator)"
|
||||
[(locked)]="metadata.translatorLocked" (onUnlock)="metadata.translatorLocked = false"
|
||||
(newItemAdded)="metadata.translatorLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);metadata.translatorLocked = true;" [settings]="getPersonsSettings(PersonRole.Translator)"
|
||||
[(locked)]="metadata.translatorLocked" (onUnlock)="metadata.translatorLocked = false"
|
||||
(newItemAdded)="metadata.translatorLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="character" class="form-label">{{t('character-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
|
||||
[(locked)]="metadata.characterLocked" (onUnlock)="metadata.characterLocked = false"
|
||||
(newItemAdded)="metadata.characterLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
|
||||
[(locked)]="metadata.characterLocked" (onUnlock)="metadata.characterLocked = false"
|
||||
(newItemAdded)="metadata.characterLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="team" class="form-label">{{t('team-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
|
||||
[(locked)]="metadata.teamLocked" (onUnlock)="metadata.teamLocked = false"
|
||||
(newItemAdded)="metadata.teamLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);metadata.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
|
||||
[(locked)]="metadata.teamLocked" (onUnlock)="metadata.teamLocked = false"
|
||||
(newItemAdded)="metadata.teamLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3">
|
||||
<label for="location" class="form-label">{{t('location-label')}}</label>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);metadata.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
|
||||
[(locked)]="metadata.locationLocked" (onUnlock)="metadata.locationLocked = false"
|
||||
(newItemAdded)="metadata.locationLocked = true">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location)" [settings]="getPersonsSettings(PersonRole.Location)"
|
||||
[(locked)]="metadata.locationLocked" (onUnlock)="metadata.locationLocked = false"
|
||||
(newItemAdded)="metadata.locationLocked = 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>
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.name}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -395,7 +471,7 @@
|
||||
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.CoverImage])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
<p class="alert alert-warning" role="alert">
|
||||
{{t('cover-image-description')}}
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
@ -412,33 +488,33 @@
|
||||
<li [ngbNavItem]="tabs[TabID.Info]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.Info])}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4>{{t('info-title')}}</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6" *ngIf="libraryName">{{t('library-title')}} {{libraryName | sentenceCase}}</div>
|
||||
<div class="col-md-6">{{t('format-title')}} <app-tag-badge>{{series.format | mangaFormat}}</app-tag-badge></div>
|
||||
<h5>{{t('info-title')}}</h5>
|
||||
<div class="row g-0 mt-3 mb-3">
|
||||
<div class="col-md-6" *ngIf="libraryName"><span class="fw-bold text-uppercase">{{t('library-title')}}</span> {{libraryName | sentenceCase}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('format-title')}}</span> <app-tag-badge>{{series.format | mangaFormat}}</app-tag-badge></div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6">{{t('created-title')}} {{series.created | date:'shortDate'}}</div>
|
||||
<div class="col-md-6">{{t('last-read-title')}} {{series.latestReadDate | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6">{{t('last-added-title')}} {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6">{{t('last-scanned-title')}} {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
|
||||
<div class="row g-0 mt-3 mb-3">
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('created-title')}}</span> {{series.created | date:'shortDate'}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('last-read-title')}}</span> {{series.latestReadDate | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('last-added-title')}}</span> {{series.lastChapterAdded | defaultDate | timeAgo}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('last-scanned-title')}}</span> {{series.lastFolderScanned | defaultDate | timeAgo}}</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-auto">{{t('folder-path-title')}} {{series.folderPath | defaultValue}}</div>
|
||||
<div class="row g-0 mt-3 mb-3">
|
||||
<div class="col-auto"><span class="fw-bold text-uppercase">{{t('folder-path-title')}}</span> {{series.folderPath | defaultValue}}</div>
|
||||
</div>
|
||||
<div class="row g-0 mb-2" *ngIf="metadata">
|
||||
<div class="row g-0 mt-3 mb-3" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
{{t('max-items-title')}} {{metadata.maxCount}}
|
||||
<span class="fw-bold text-uppercase">{{t('max-items-title')}}</span> {{metadata.maxCount}}
|
||||
<i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="t('highest-count-tooltip')" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{t('total-items-title')}} {{metadata.totalCount}}
|
||||
<span class="fw-bold text-uppercase">{{t('total-items-title')}}</span> {{metadata.totalCount}}
|
||||
<i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="t('max-issue-tooltip')" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
<div class="col-md-6">{{t('publication-status-title')}} {{metadata.publicationStatus | publicationStatus}}</div>
|
||||
<div class="col-md-6">{{t('total-pages-title')}} {{series.pages}}</div>
|
||||
<div class="col-md-6">{{t('size-title')}} {{size | bytes}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('publication-status-title')}}</span> {{metadata.publicationStatus | publicationStatus}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('total-pages-title')}}</span> {{series.pages}}</div>
|
||||
<div class="col-md-6"><span class="fw-bold text-uppercase">{{t('size-title')}}</span> {{size | bytes}}</div>
|
||||
</div>
|
||||
<h4>Volumes</h4>
|
||||
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
||||
|
@ -61,6 +61,7 @@ import {Action, ActionFactoryService, ActionItem} from "../../../_services/actio
|
||||
import {SettingButtonComponent} from "../../../settings/_components/setting-button/setting-button.component";
|
||||
import {ActionService} from "../../../_services/action.service";
|
||||
import {DownloadService} from "../../../shared/_services/download.service";
|
||||
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
@ -114,6 +115,7 @@ const blackList = [Action.Edit, Action.Info, Action.IncognitoRead, Action.Read,
|
||||
UtcToLocalTimePipe,
|
||||
EditListComponent,
|
||||
SettingButtonComponent,
|
||||
SettingItemComponent,
|
||||
],
|
||||
templateUrl: './edit-series-modal.component.html',
|
||||
styleUrls: ['./edit-series-modal.component.scss'],
|
||||
@ -418,7 +420,8 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
const presetIds = presetField.map(p => p.id);
|
||||
personSettings.savedData = people.filter(person => presetIds.includes(person.id));
|
||||
this.peopleSettings[role] = personSettings;
|
||||
this.updatePerson(personSettings.savedData as Person[], role);
|
||||
this.metadataService.updatePerson(this.metadata, personSettings.savedData as Person[], role);
|
||||
this.cdRef.markForCheck();
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
@ -550,8 +553,8 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
}
|
||||
|
||||
|
||||
if (selectedIndex > 0 && this.selectedCover !== '') {
|
||||
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
|
||||
if (selectedIndex > 0 || this.coverImageReset) {
|
||||
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover, !this.coverImageReset));
|
||||
}
|
||||
|
||||
this.saveNestedComponents.emit();
|
||||
@ -574,6 +577,12 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updatePerson(persons: Person[], role: PersonRole) {
|
||||
this.metadataService.updatePerson(this.metadata, persons, role);
|
||||
this.metadata.locationLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateLanguage(language: Array<Language>) {
|
||||
if (language.length === 0) {
|
||||
this.metadata.language = '';
|
||||
@ -583,56 +592,6 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updatePerson(persons: Person[], role: PersonRole) {
|
||||
switch (role) {
|
||||
case PersonRole.Other:
|
||||
break;
|
||||
case PersonRole.Artist:
|
||||
break;
|
||||
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.Imprint:
|
||||
this.metadata.imprints = persons;
|
||||
break;
|
||||
case PersonRole.Team:
|
||||
this.metadata.teams = persons;
|
||||
break;
|
||||
case PersonRole.Location:
|
||||
this.metadata.locations = persons;
|
||||
break;
|
||||
case PersonRole.Writer:
|
||||
this.metadata.writers = persons;
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.metadata.translators = persons;
|
||||
break;
|
||||
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.editSeriesForm.patchValue({
|
||||
coverImageIndex: index
|
||||
|
@ -48,6 +48,8 @@ import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.compo
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component";
|
||||
import {CarouselTabsComponent} from "../../carousel/_components/carousel-tabs/carousel-tabs.component";
|
||||
import {CarouselTabComponent} from "../../carousel/_components/carousel-tab/carousel-tab.component";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
@ -60,7 +62,7 @@ enum TabID {
|
||||
@Component({
|
||||
selector: 'app-card-detail-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent],
|
||||
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent, CarouselTabsComponent, CarouselTabComponent],
|
||||
templateUrl: './card-detail-drawer.component.html',
|
||||
styleUrls: ['./card-detail-drawer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -45,7 +45,7 @@
|
||||
|
||||
<div class="card-overlay"></div>
|
||||
@if (overlayInformation | safeHtml; as info) {
|
||||
@if (info !== '' || info !== null) {
|
||||
@if (info) {
|
||||
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}">
|
||||
<div class="position-relative">
|
||||
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span>
|
||||
@ -54,8 +54,20 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="card-body meta-title">
|
||||
@if (subtitleTemplate) {
|
||||
<div style="text-align: center">
|
||||
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (!suppressLibraryLink && libraryName) {
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active">
|
||||
{{libraryName | sentenceCase}}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
@if (title.length > 0 || actions.length > 0) {
|
||||
<div class="card-body">
|
||||
<div class="card-title-container">
|
||||
<div>
|
||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
||||
<app-promoted-icon [promoted]="isPromoted()"></app-promoted-icon>
|
||||
@ -68,17 +80,6 @@
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (subtitleTemplate) {
|
||||
<div style="text-align: center">
|
||||
<ng-container [ngTemplateOutlet]="subtitleTemplate" [ngTemplateOutletContext]="{ $implicit: entity }"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@if (!suppressLibraryLink && libraryName) {
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active">
|
||||
{{libraryName | sentenceCase}}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
@ -1,113 +1 @@
|
||||
|
||||
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.error-banner {
|
||||
width: $image-width;
|
||||
height: 18px;
|
||||
background-color: var(--toast-error-bg-color);
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.selected-highlight {
|
||||
outline: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
.progress-banner {
|
||||
width: $image-width;
|
||||
height: 5px;
|
||||
|
||||
.progress {
|
||||
color: var(--card-progress-bar-color);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 30%;
|
||||
}
|
||||
|
||||
.badge-container {
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
height: $image-height;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 158px;
|
||||
}
|
||||
|
||||
.not-read-badge {
|
||||
position: absolute;
|
||||
top: calc(-1 * (var(--card-progress-triangle-size) / 2));
|
||||
right: -14px;
|
||||
z-index: 1000;
|
||||
height: var(--card-progress-triangle-size);
|
||||
width: var(--card-progress-triangle-size);
|
||||
background-color: var(--primary-color);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
|
||||
.bulk-mode {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
visibility: hidden;
|
||||
|
||||
&.always-show {
|
||||
visibility: visible !important;
|
||||
width: $image-width;
|
||||
height: $image-height;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--checkbox-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.overlay {
|
||||
&:hover {
|
||||
.bulk-mode {
|
||||
visibility: visible;
|
||||
z-index: 110;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
position: absolute;
|
||||
top: 228px;
|
||||
right: 5px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.library {
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-top: 0px;
|
||||
}
|
||||
@use '../../../card-item-common';
|
||||
|
@ -0,0 +1,95 @@
|
||||
<ng-container *transloco="let t; read: 'card-item'">
|
||||
<div class="card-item-container card position-relative {{selected ? 'selected-highlight' : ''}}" >
|
||||
<div class="overlay" (click)="handleClick($event)">
|
||||
@if (chapter.pages > 0 || suppressArchiveWarning) {
|
||||
<app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
|
||||
} @else if (chapter.pages === 0 && !suppressArchiveWarning) {
|
||||
<app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageService.errorImage"></app-image>
|
||||
}
|
||||
|
||||
<div class="progress-banner">
|
||||
@if (chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages && chapter.pages > 0 && chapter.pagesRead !== chapter.pages) {
|
||||
<p ngbTooltip="{{((chapter.pagesRead / chapter.pages) * 100) | number:'1.0-1'}}% Read">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="chapter.pagesRead" [max]="chapter.pages"></ngb-progressbar>
|
||||
</p>
|
||||
}
|
||||
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(chapter.pages === 0 && !suppressArchiveWarning) {
|
||||
<div class="error-banner">
|
||||
{{t('cannot-read')}}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (chapter.pagesRead === 0 && chapter.pages > 0) {
|
||||
<div class="badge-container">
|
||||
<div class="not-read-badge"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (allowSelection) {
|
||||
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)">
|
||||
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{chapter.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (chapter.files.length > 1) {
|
||||
<div class="count">
|
||||
<span class="badge bg-primary">{{chapter.files.length}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-overlay"></div>
|
||||
<div class="chapter overlay-information">
|
||||
<div class="overlay-information--centered">
|
||||
<span class="card-title library mx-auto" style="width: auto;" (click)="read($event)">
|
||||
<!-- Card Image -->
|
||||
<div>
|
||||
<i class="fa-solid fa-book" aria-hidden="true"></i>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (chapter.isSpecial) {
|
||||
<div class="card-body meta-title" [ngbTooltip]="chapter.title.length > 34 ? chapter.title : null">
|
||||
<div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;">
|
||||
{{chapter.title || chapter.range}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
} @else if (chapter.titleName) {
|
||||
<div class="card-body meta-title" [ngbTooltip]="chapter.titleName.length > 34 ? chapter.titleName : null">
|
||||
<div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;">
|
||||
{{chapter.titleName}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<div class="card-title-container">
|
||||
<span class="card-title" id="{{chapter.id}}" tabindex="0" [ngbTooltip]="chapter.isSpecial ? (chapter.title || chapter.range) : null">
|
||||
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/chapter/{{chapter.id}}">
|
||||
@if (chapter.isSpecial) {
|
||||
{{chapter.title || chapter.range}}
|
||||
} @else {
|
||||
<app-entity-title [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title>
|
||||
}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@if (actions && actions.length > 0) {
|
||||
<span class="card-actions float-end">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@ -0,0 +1 @@
|
||||
@use '../../../card-item-common';
|
207
UI/Web/src/app/cards/chapter-card/chapter-card.component.ts
Normal file
207
UI/Web/src/app/cards/chapter-card/chapter-card.component.ts
Normal file
@ -0,0 +1,207 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter, HostListener,
|
||||
inject,
|
||||
Input, OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {BulkSelectionService} from "../bulk-selection.service";
|
||||
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
|
||||
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {ScrollService} from "../../_services/scroll.service";
|
||||
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {Observable} from "rxjs";
|
||||
import {User} from "../../_models/user";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {EntityTitleComponent} from "../entity-title/entity-title.component";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
import {Router, RouterLink} from "@angular/router";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {filter, map} from "rxjs/operators";
|
||||
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-card',
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgbTooltip,
|
||||
NgbProgressbar,
|
||||
DecimalPipe,
|
||||
ImageComponent,
|
||||
DownloadIndicatorComponent,
|
||||
FormsModule,
|
||||
EntityTitleComponent,
|
||||
CardActionablesComponent,
|
||||
RouterLink,
|
||||
TranslocoDirective,
|
||||
DefaultValuePipe
|
||||
],
|
||||
templateUrl: './chapter-card.component.html',
|
||||
styleUrl: './chapter-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChapterCardComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
public readonly imageService = inject(ImageService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
|
||||
@Input({required: true}) libraryId: number = 0;
|
||||
@Input({required: true}) seriesId: number = 0;
|
||||
@Input({required: true}) chapter!: Chapter;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
/**
|
||||
* Any actions to perform on the card
|
||||
*/
|
||||
@Input() actions: ActionItem<Chapter>[] = [];
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
@Input() selected: boolean = false;
|
||||
/**
|
||||
* If the entity should show selection code
|
||||
*/
|
||||
@Input() allowSelection: boolean = false;
|
||||
/**
|
||||
* This will suppress the "cannot read archive warning" when total pages is 0
|
||||
*/
|
||||
@Input() suppressArchiveWarning: boolean = false;
|
||||
/**
|
||||
* When the card is selected.
|
||||
*/
|
||||
@Output() selection = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
*/
|
||||
download$: Observable<DownloadEvent | null> | null = null;
|
||||
/**
|
||||
* Handles touch events for selection on mobile devices
|
||||
*/
|
||||
prevTouchTime: number = 0;
|
||||
/**
|
||||
* Handles touch events for selection on mobile devices to ensure you aren't touch scrolling
|
||||
*/
|
||||
prevOffset: number = 0;
|
||||
selectionInProgress: boolean = false;
|
||||
|
||||
private user: User | undefined;
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
onTouchMove(event: TouchEvent) {
|
||||
if (!this.allowSelection) return;
|
||||
|
||||
this.selectionInProgress = false;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
onTouchStart(event: TouchEvent) {
|
||||
if (!this.allowSelection) return;
|
||||
|
||||
this.prevTouchTime = event.timeStamp;
|
||||
this.prevOffset = this.scrollService.scrollPosition;
|
||||
this.selectionInProgress = true;
|
||||
}
|
||||
|
||||
@HostListener('touchend', ['$event'])
|
||||
onTouchEnd(event: TouchEvent) {
|
||||
if (!this.allowSelection) return;
|
||||
const delta = event.timeStamp - this.prevTouchTime;
|
||||
const verticalOffset = this.scrollService.scrollPosition;
|
||||
|
||||
if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset) && this.selectionInProgress) {
|
||||
this.handleSelection();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
this.prevTouchTime = 0;
|
||||
this.selectionInProgress = false;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.filterSendTo();
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
return this.downloadService.mapToEntityType(events, this.chapter);
|
||||
}));
|
||||
|
||||
|
||||
this.messageHub.messages$.pipe(filter(event => event.event === EVENTS.UserProgressUpdate),
|
||||
map(evt => evt.payload as UserProgressUpdateEvent), takeUntilDestroyed(this.destroyRef)).subscribe(updateEvent => {
|
||||
if (this.user === undefined || this.user.username !== updateEvent.username) return;
|
||||
if (updateEvent.chapterId !== this.chapter.id) return;
|
||||
|
||||
this.chapter.pagesRead = updateEvent.pagesRead;
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
handleSelection(event?: any) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.selection.emit(this.selected);
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
|
||||
filterSendTo() {
|
||||
if (!this.actions || this.actions.length === 0) return;
|
||||
|
||||
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.chapter);
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
this.downloadService.download('chapter', this.chapter);
|
||||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.chapter);
|
||||
}
|
||||
}
|
||||
|
||||
handleClick(event: any) {
|
||||
if (this.bulkSelectionService.hasSelections()) {
|
||||
this.handleSelection(event);
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'chapter', this.chapter.id]);
|
||||
}
|
||||
|
||||
read(event: any) {
|
||||
event.stopPropagation();
|
||||
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
|
||||
}
|
||||
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
}
|
@ -17,16 +17,16 @@
|
||||
</td>
|
||||
<td>
|
||||
@if(editMode[idx]) {
|
||||
<input type="number" formControlName="pagesRead" class="form-control"/>
|
||||
<input type="number" formControlName="pagesRead" class="form-control" inputmode="numeric"/>
|
||||
} @else {
|
||||
{{progressEvents[idx].pagesRead}}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{{progressEvents[idx].createdUtc}}
|
||||
{{progressEvents[idx].createdUtc | utcToLocalTime | date:'shortDate' | defaultDate}}
|
||||
</td>
|
||||
<td>
|
||||
{{progressEvents[idx].lastModifiedUtc}}
|
||||
{{progressEvents[idx].lastModifiedUtc | utcToLocalTime | date:'shortDate' | defaultDate}}
|
||||
</td>
|
||||
<!-- <td>-->
|
||||
<!-- @if(editMode[idx]) {-->
|
||||
@ -42,6 +42,8 @@
|
||||
<!-- }-->
|
||||
<!-- </td>-->
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="6" style="text-align: center;">{{t('no-data')}}</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {AsyncPipe, NgForOf, TitleCasePipe} from "@angular/common";
|
||||
import {AsyncPipe, DatePipe, NgForOf, TitleCasePipe} from "@angular/common";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {FullProgress} from "../../_models/readers/full-progress";
|
||||
@ -8,6 +8,7 @@ import {ReaderService} from "../../_services/reader.service";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-chapter-progress',
|
||||
@ -20,7 +21,9 @@ import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
UtcToLocalTimePipe,
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SentenceCasePipe
|
||||
SentenceCasePipe,
|
||||
DatePipe,
|
||||
DefaultDatePipe
|
||||
],
|
||||
templateUrl: './edit-chapter-progress.component.html',
|
||||
styleUrl: './edit-chapter-progress.component.scss',
|
||||
|
@ -1,51 +1,51 @@
|
||||
<ng-container *transloco="let t; read: 'entity-title'">
|
||||
<ng-container [ngSwitch]="libraryType">
|
||||
<ng-container *ngSwitchCase="LibraryType.Comic">
|
||||
<ng-container *ngIf="titleName !== '' && prioritizeTitleName; else fullComicTitle">
|
||||
@switch (libraryType) {
|
||||
@case (LibraryType.Comic) {
|
||||
@if (titleName !== '' && prioritizeTitleName) {
|
||||
{{titleName}}
|
||||
</ng-container>
|
||||
<ng-template #fullComicTitle>
|
||||
} @else {
|
||||
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
|
||||
<ng-container *ngIf="includeVolume && volumeTitle !== ''">
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
</ng-container>
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + Number : volumeTitle) : t('special')}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@if (includeVolume && volumeTitle !== '') {
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
}
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + number : volumeTitle) : t('special')}}
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container *ngSwitchCase="LibraryType.ComicVine">
|
||||
<ng-container *ngIf="titleName !== '' && prioritizeTitleName; else fullComicTitle">
|
||||
@case (LibraryType.ComicVine) {
|
||||
@if (titleName !== '' && prioritizeTitleName) {
|
||||
{{titleName}}
|
||||
</ng-container>
|
||||
<ng-template #fullComicTitle>
|
||||
} @else {
|
||||
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
|
||||
<ng-container *ngIf="includeVolume && volumeTitle !== ''">
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
</ng-container>
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + Number : volumeTitle) : t('special')}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@if (includeVolume && volumeTitle !== '') {
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
}
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + number : volumeTitle) : t('special')}}
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container *ngSwitchCase="LibraryType.Manga">
|
||||
<ng-container *ngIf="titleName !== '' && prioritizeTitleName; else fullMangaTitle">
|
||||
@case (LibraryType.Manga) {
|
||||
@if (titleName !== '' && prioritizeTitleName) {
|
||||
{{titleName}}
|
||||
</ng-container>
|
||||
<ng-template #fullMangaTitle>
|
||||
} @else {
|
||||
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
|
||||
<ng-container *ngIf="includeVolume && volumeTitle !== ''">
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
</ng-container>
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + Number : volumeTitle) : t('special')}}
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LibraryType.Book">
|
||||
@if (includeVolume && volumeTitle !== '') {
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
|
||||
}
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}
|
||||
}
|
||||
}
|
||||
|
||||
@case (LibraryType.Book) {
|
||||
{{volumeTitle}}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LibraryType.LightNovel">
|
||||
}
|
||||
|
||||
@case (LibraryType.LightNovel) {
|
||||
{{volumeTitle}}
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="LibraryType.Images">
|
||||
{{Number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + Number : volumeTitle) : t('special')}}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
@case (LibraryType.Images) {
|
||||
{{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
|
@ -3,7 +3,6 @@ import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter, LooseLeafOrDefaultNumber } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library/library';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import {CommonModule, NgSwitch} from "@angular/common";
|
||||
import {TranslocoModule} from "@jsverse/transloco";
|
||||
|
||||
/**
|
||||
@ -13,8 +12,6 @@ import {TranslocoModule} from "@jsverse/transloco";
|
||||
selector: 'app-entity-title',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgSwitch,
|
||||
TranslocoModule
|
||||
],
|
||||
templateUrl: './entity-title.component.html',
|
||||
@ -46,10 +43,7 @@ export class EntityTitleComponent implements OnInit {
|
||||
titleName: string = '';
|
||||
volumeTitle: string = '';
|
||||
|
||||
get Number() {
|
||||
if (this.isChapter) return (this.entity as Chapter).range;
|
||||
return (this.entity as Volume).name;
|
||||
}
|
||||
number: string = '';
|
||||
|
||||
|
||||
constructor(private utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) {}
|
||||
@ -57,12 +51,11 @@ export class EntityTitleComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.entity);
|
||||
|
||||
|
||||
|
||||
if (this.isChapter) {
|
||||
const c = (this.entity as Chapter);
|
||||
this.volumeTitle = c.volumeTitle || '';
|
||||
this.titleName = c.titleName || '';
|
||||
this.number = c.range;
|
||||
} else {
|
||||
const v = this.utilityService.asVolume(this.entity);
|
||||
this.volumeTitle = v.name || '';
|
||||
@ -70,6 +63,7 @@ export class EntityTitleComponent implements OnInit {
|
||||
if (v.chapters[0].titleName) {
|
||||
this.titleName += ' - ' + v.chapters[0].titleName;
|
||||
}
|
||||
this.number = v.name;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<ng-container *transloco="let t; read: 'external-series-card'">
|
||||
<ng-container *ngIf="data !== undefined">
|
||||
<div class="card-item-container card clickable">
|
||||
@if (data !== undefined) {
|
||||
<div class="card-item-container card clickable position-relative">
|
||||
<div class="overlay" (click)="handleClick()">
|
||||
<ng-container>
|
||||
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" [imageUrl]="data.coverUrl"></app-image>
|
||||
@ -17,20 +17,18 @@
|
||||
|
||||
</div>
|
||||
|
||||
@if (data.name.length > 0) {
|
||||
<div class="card-body">
|
||||
<div>
|
||||
<div class="card-title-container">
|
||||
@if (data.name.length > 0) {
|
||||
<a #link class="card-title" [href]="data.url" target="_blank" rel="noreferrer nofollow">
|
||||
<span class="card-title" placement="top" id="{{data.name}}" [ngbTooltip]="data.name" (click)="handleClick()" tabindex="0">
|
||||
<img class="me-1" [ngSrc]="data.provider | providerImage" width="20" height="20" alt="">
|
||||
{{data.name}}
|
||||
</span>
|
||||
</div>
|
||||
<a #link class="card-title library" [href]="data.url" target="_blank" rel="noreferrer nofollow">{{t('open-external')}}</a>
|
||||
</div>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
}
|
||||
</ng-container>
|
||||
|
@ -0,0 +1,14 @@
|
||||
@use '../../../card-item-common';
|
||||
|
||||
a {
|
||||
color: var(--card-text-color);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.card-title {
|
||||
padding: 5px;
|
||||
max-width: unset;
|
||||
}
|
@ -5,7 +5,7 @@ import {
|
||||
Input,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
import {ExternalSeries} from "../../_models/series-detail/external-series";
|
||||
import {RouterLinkActive} from "@angular/router";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
@ -19,7 +19,7 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
@Component({
|
||||
selector: 'app-external-series-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ImageComponent, NgbProgressbar, NgbTooltip, ReactiveFormsModule, RouterLinkActive, TranslocoDirective, NgOptimizedImage, ProviderImagePipe, SafeHtmlPipe],
|
||||
imports: [ImageComponent, NgbProgressbar, NgbTooltip, ReactiveFormsModule, RouterLinkActive, TranslocoDirective, NgOptimizedImage, ProviderImagePipe, SafeHtmlPipe],
|
||||
templateUrl: './external-series-card.component.html',
|
||||
styleUrls: ['./external-series-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -37,7 +37,7 @@ export class ExternalSeriesCardComponent {
|
||||
|
||||
handleClick() {
|
||||
if (this.previewOnClick) {
|
||||
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
|
||||
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: ''});
|
||||
ref.componentInstance.isExternalSeries = true;
|
||||
ref.componentInstance.aniListId = this.data.aniListId;
|
||||
ref.componentInstance.malId = this.data.malId;
|
||||
|
@ -1,23 +1,26 @@
|
||||
<div class="card-item-container card">
|
||||
<div class="overlay">
|
||||
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" classes="extreme-blur" [imageUrl]="imageUrl"></app-image>
|
||||
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="230px" width="158px" classes="extreme-blur"
|
||||
[imageUrl]="imageUrl"></app-image>
|
||||
|
||||
<div class="card-overlay"></div>
|
||||
<ng-container *ngIf="entity.title | safeHtml as info">
|
||||
<div class="overlay-information overlay-information--centered" *ngIf="info !== ''">
|
||||
<div class="position-relative">
|
||||
<span class="card-title library mx-auto" style="width: auto;">
|
||||
<i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i>
|
||||
<div class="upcoming-header">Upcoming</div>
|
||||
<span [innerHTML]="info"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="entity.title | safeHtml as info">
|
||||
<div class="card-body meta-title" *ngIf="info !== ''">
|
||||
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center">
|
||||
|
||||
<div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>Upcoming</div>
|
||||
<span [innerHTML]="info"></span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<span class="card-title" tabindex="0">{{title}}</span>
|
||||
</div>
|
||||
<div class="card-title-container">
|
||||
<span class="card-title" tabindex="0">
|
||||
{{title}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
@ -1,3 +1,5 @@
|
||||
@use '../../../card-item-common';
|
||||
|
||||
::ng-deep .extreme-blur {
|
||||
filter: brightness(50%) blur(4px)
|
||||
}
|
||||
@ -7,9 +9,14 @@
|
||||
}
|
||||
|
||||
.upcoming-header {
|
||||
font-size: 16px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
width: 146px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
@ -1,7 +1,80 @@
|
||||
<ng-container *ngIf="data !== undefined">
|
||||
<app-card-item [title]="data.name" [actions]="actions" [suppressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl"
|
||||
[entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"
|
||||
[allowSelection]="allowSelection" (selection)="selection.emit(selected)" [selected]="selected"
|
||||
[overlayInformation]="(relation | relationship)"
|
||||
></app-card-item>
|
||||
</ng-container>
|
||||
<ng-container *transloco="let t; read: 'card-item'">
|
||||
<div class="card-item-container card position-relative {{selected ? 'selected-highlight' : ''}}">
|
||||
<div class="overlay" (click)="handleClick()">
|
||||
@if (series.pages > 0) {
|
||||
<app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-image>
|
||||
} @else if (series.pages === 0) {
|
||||
<app-image height="230px" width="158px" [styles]="{'border-radius': '.25rem .25rem 0 0'}" [imageUrl]="imageService.errorImage"></app-image>
|
||||
}
|
||||
|
||||
<div class="progress-banner">
|
||||
@if (series.pagesRead > 0 && series.pagesRead < series.pages && series.pages > 0 && series.pagesRead !== series.pages) {
|
||||
<p ngbTooltip="{{((series.pagesRead / series.pages) * 100) | number:'1.0-1'}}%">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
|
||||
</p>
|
||||
}
|
||||
|
||||
<span class="download">
|
||||
<app-download-indicator [download$]="download$"></app-download-indicator>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if(series.pages === 0) {
|
||||
<div class="error-banner">
|
||||
{{t('cannot-read')}}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (series.pagesRead === 0 && series.pages > 0) {
|
||||
<div class="badge-container">
|
||||
<div class="not-read-badge"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (allowSelection) {
|
||||
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)">
|
||||
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{series.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (count > 1) {
|
||||
<div class="count">
|
||||
<span class="badge bg-primary">{{count}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-overlay"></div>
|
||||
<div class="series overlay-information">
|
||||
<div class="overlay-information--centered">
|
||||
<span class="card-title library mx-auto" style="width: auto;" (click)="read($event)">
|
||||
<!-- Card Image -->
|
||||
<div>
|
||||
<i class="fa-solid fa-book" aria-hidden="true"></i>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body meta-title" [ngbTooltip]="series.localizedName.length > 34 ? series.localizedName : null">
|
||||
<div class="card-content d-flex justify-content-center align-items-center text-center" style="width:100%;min-height:58px;">
|
||||
{{relation ? (relation | relationship) : (series.localizedName | defaultValue)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-title-container">
|
||||
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}" tabindex="0">
|
||||
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}">
|
||||
{{series.name}}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@if (actions && actions.length > 0) {
|
||||
<span class="card-actions float-end">
|
||||
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
@ -0,0 +1 @@
|
||||
@use '../../../card-item-common';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user