misc stuff to avoid scan loop (#1389)
* Implemented a workaround for nginx users with BlockCommonExploits enabled, which would interfere with book image escaping done by Kavita when images had ../ in their path. * Added back to top support on all pages but those that untilize virtual scrolling without a parent scroll. * Hide jumpbar on pages where there is no scroll * Refactored jumbar code into a dedicated service * Stash some jumpkey resume code as I can't get it working with the virtual scroller. * Don't allow non-admins to see File locations on card detail drawer. * Some cleanup on GetServerInfo * When an error occurs in register, delete the user on exception. * Fixed a NPE in Stat collection for brand new users * When we catch an exception on registering a new user, delete the user as rolling back doesn't do anything. * Don't close typeahead when we are selecting options from it * Added shortcut key H to open shortcut modal on manga reader * When processing progress updates on cards, for volumes, properly find the chapter to update pages read. * Hide cover image on reading list if it's not set and fixed a missing closing div tag * Hide collection poster when nothing is set on collection detail * Small fix around updating state * Sped up the bookmark image call by removing one DB call * Fixed broken test from change in bookmark code * Fixed an oversight where if there is no tag in ComicInfo after a chapter was updated with People or Genres, then the People/Genres would never be removed. * Added test with TagHelper * Fixed a bug where 2 clear buttons would show on search bar due to browser injecting their own. Search bar wont show clear button until text is typed. * Fixed a bug where InstallID wasn't being selected correctly in converter
This commit is contained in:
parent
b90c6aa76c
commit
5812588fe5
36 changed files with 474 additions and 249 deletions
|
@ -107,4 +107,25 @@ public class GenreHelperTests
|
||||||
|
|
||||||
Assert.Equal(1, genreRemoved.Count);
|
Assert.Equal(1, genreRemoved.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveEveryoneIfNothingInRemoveAllExcept()
|
||||||
|
{
|
||||||
|
var existingGenres = new List<Genre>
|
||||||
|
{
|
||||||
|
DbFactory.Genre("Action", false),
|
||||||
|
DbFactory.Genre("Sci-fi", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var peopleFromChapters = new List<Genre>();
|
||||||
|
|
||||||
|
var genreRemoved = new List<Genre>();
|
||||||
|
GenreHelper.KeepOnlySameGenreBetweenLists(existingGenres,
|
||||||
|
peopleFromChapters, genre =>
|
||||||
|
{
|
||||||
|
genreRemoved.Add(genre);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, genreRemoved.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
@ -92,6 +93,25 @@ public class PersonHelperTests
|
||||||
Assert.Equal(2, peopleRemoved.Count);
|
Assert.Equal(2, peopleRemoved.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemovePeople_ShouldRemovePeopleOfSameRole_WhenNothingPassed()
|
||||||
|
{
|
||||||
|
var existingPeople = new List<Person>
|
||||||
|
{
|
||||||
|
DbFactory.Person("Joe Shmo", PersonRole.Writer),
|
||||||
|
DbFactory.Person("Joe Shmo", PersonRole.Writer),
|
||||||
|
DbFactory.Person("Joe Shmo", PersonRole.CoverArtist)
|
||||||
|
};
|
||||||
|
var peopleRemoved = new List<Person>();
|
||||||
|
PersonHelper.RemovePeople(existingPeople, Array.Empty<string>(), PersonRole.Writer, person =>
|
||||||
|
{
|
||||||
|
peopleRemoved.Add(person);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotEqual(existingPeople, peopleRemoved);
|
||||||
|
Assert.Equal(2, peopleRemoved.Count);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void KeepOnlySamePeopleBetweenLists()
|
public void KeepOnlySamePeopleBetweenLists()
|
||||||
{
|
{
|
||||||
|
@ -137,4 +157,5 @@ public class PersonHelperTests
|
||||||
PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo Two", PersonRole.CoverArtist));
|
PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo Two", PersonRole.CoverArtist));
|
||||||
Assert.Equal(4, existingPeople.Count);
|
Assert.Equal(4, existingPeople.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -116,4 +116,25 @@ public class TagHelperTests
|
||||||
|
|
||||||
Assert.Equal(1, tagRemoved.Count);
|
Assert.Equal(1, tagRemoved.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveEveryoneIfNothingInRemoveAllExcept()
|
||||||
|
{
|
||||||
|
var existingTags = new List<Tag>
|
||||||
|
{
|
||||||
|
DbFactory.Tag("Action", false),
|
||||||
|
DbFactory.Tag("Sci-fi", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
var peopleFromChapters = new List<Tag>();
|
||||||
|
|
||||||
|
var tagRemoved = new List<Tag>();
|
||||||
|
TagHelper.KeepOnlySameTagBetweenLists(existingTags,
|
||||||
|
peopleFromChapters, tag =>
|
||||||
|
{
|
||||||
|
tagRemoved.Add(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(2, tagRemoved.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ using API.Data.Repositories;
|
||||||
using API.DTOs.Reader;
|
using API.DTOs.Reader;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
|
@ -44,7 +45,9 @@ public class BookmarkServiceTests
|
||||||
_context = new DataContext(contextOptions);
|
_context = new DataContext(contextOptions);
|
||||||
Task.Run(SeedDb).GetAwaiter().GetResult();
|
Task.Run(SeedDb).GetAwaiter().GetResult();
|
||||||
|
|
||||||
_unitOfWork = new UnitOfWork(_context, Substitute.For<IMapper>(), null);
|
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||||
|
var mapper = config.CreateMapper();
|
||||||
|
_unitOfWork = new UnitOfWork(_context, mapper, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private BookmarkService Create(IDirectoryService ds)
|
private BookmarkService Create(IDirectoryService ds)
|
||||||
|
|
|
@ -830,6 +830,44 @@ public class SeriesServiceTests
|
||||||
Assert.True(series.Metadata.PublisherLocked);
|
Assert.True(series.Metadata.PublisherLocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson()
|
||||||
|
{
|
||||||
|
await ResetDb();
|
||||||
|
var s = new Series()
|
||||||
|
{
|
||||||
|
Name = "Test",
|
||||||
|
Library = new Library()
|
||||||
|
{
|
||||||
|
Name = "Test LIb",
|
||||||
|
Type = LibraryType.Book,
|
||||||
|
},
|
||||||
|
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>())
|
||||||
|
};
|
||||||
|
var g = DbFactory.Person("Existing Person", PersonRole.Publisher);
|
||||||
|
_context.Series.Add(s);
|
||||||
|
|
||||||
|
_context.Person.Add(g);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var success = await _seriesService.UpdateSeriesMetadata(new UpdateSeriesMetadataDto()
|
||||||
|
{
|
||||||
|
SeriesMetadata = new SeriesMetadataDto()
|
||||||
|
{
|
||||||
|
SeriesId = 1,
|
||||||
|
Publishers = new List<PersonDto>() {},
|
||||||
|
},
|
||||||
|
CollectionTags = new List<CollectionTagDto>()
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(success);
|
||||||
|
|
||||||
|
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
|
||||||
|
Assert.NotNull(series.Metadata);
|
||||||
|
Assert.False(series.Metadata.People.Any());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateSeriesMetadata_ShouldLockIfTold()
|
public async Task UpdateSeriesMetadata_ShouldLockIfTold()
|
||||||
{
|
{
|
||||||
|
|
|
@ -143,7 +143,10 @@ namespace API.Controllers
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Something went wrong when registering user");
|
_logger.LogError(ex, "Something went wrong when registering user");
|
||||||
await _unitOfWork.RollbackAsync();
|
// We need to manually delete the User as we've already committed
|
||||||
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(registerDto.Username);
|
||||||
|
_unitOfWork.UserRepository.Delete(user);
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return BadRequest("Something went wrong when registering user");
|
return BadRequest("Something went wrong when registering user");
|
||||||
|
@ -175,7 +178,7 @@ namespace API.Controllers
|
||||||
|
|
||||||
if (!validPassword)
|
if (!validPassword)
|
||||||
{
|
{
|
||||||
return Unauthorized("Your credentials are not correct");
|
return Unauthorized("Your credentials are not correct"); // TODO: Refactor backend to send back the string for i8ln
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await _signInManager
|
var result = await _signInManager
|
||||||
|
|
|
@ -110,6 +110,8 @@ namespace API.Controllers
|
||||||
{
|
{
|
||||||
if (page < 0) page = 0;
|
if (page < 0) page = 0;
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
|
|
||||||
|
// NOTE: I'm not sure why I need this flow here
|
||||||
var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId);
|
var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId);
|
||||||
if (page > totalPages)
|
if (page > totalPages)
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public string Summary { get; set; }
|
public string Summary { get; set; }
|
||||||
public bool Promoted { get; set; }
|
public bool Promoted { get; set; }
|
||||||
|
public string CoverImage { get; set; }
|
||||||
public bool CoverImageLocked { get; set; }
|
public bool CoverImageLocked { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,10 @@ namespace API.DTOs.Settings
|
||||||
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
|
/// <remarks>If null or empty string, will default back to default install setting aka <see cref="EmailService.DefaultApiUrl"/></remarks>
|
||||||
public string EmailServiceUrl { get; set; }
|
public string EmailServiceUrl { get; set; }
|
||||||
public string InstallVersion { get; set; }
|
public string InstallVersion { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a unique Id to this Kavita installation. Only used in Stats to identify unique installs.
|
||||||
|
/// </summary>
|
||||||
|
public string InstallId { get; set; }
|
||||||
|
|
||||||
public bool ConvertBookmarkToWebP { get; set; }
|
public bool ConvertBookmarkToWebP { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
@ -1021,6 +1021,13 @@ public class SeriesRepository : ISeriesRepository
|
||||||
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a list of Series that the user Has fully read
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <param name="libraryId"></param>
|
||||||
|
/// <param name="userParams"></param>
|
||||||
|
/// <returns></returns>
|
||||||
public async Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams)
|
public async Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams)
|
||||||
{
|
{
|
||||||
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
|
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
|
||||||
|
|
|
@ -57,6 +57,9 @@ namespace API.Helpers.Converters
|
||||||
case ServerSettingKey.TotalBackups:
|
case ServerSettingKey.TotalBackups:
|
||||||
destination.TotalBackups = int.Parse(row.Value);
|
destination.TotalBackups = int.Parse(row.Value);
|
||||||
break;
|
break;
|
||||||
|
case ServerSettingKey.InstallId:
|
||||||
|
destination.InstallId = row.Value;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ public static class PersonHelper
|
||||||
/// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and
|
/// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and
|
||||||
/// add an entry. For each person in name, the callback will be executed.
|
/// add an entry. For each person in name, the callback will be executed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This does not remove people if an empty list is passed into names</remarks>
|
||||||
/// <remarks>This is used to add new people to a list without worrying about duplicating rows in the DB</remarks>
|
/// <remarks>This is used to add new people to a list without worrying about duplicating rows in the DB</remarks>
|
||||||
/// <param name="allPeople"></param>
|
/// <param name="allPeople"></param>
|
||||||
/// <param name="names"></param>
|
/// <param name="names"></param>
|
||||||
|
@ -48,6 +49,17 @@ public static class PersonHelper
|
||||||
public static void RemovePeople(ICollection<Person> existingPeople, IEnumerable<string> people, PersonRole role, Action<Person> action = null)
|
public static void RemovePeople(ICollection<Person> existingPeople, IEnumerable<string> people, PersonRole role, Action<Person> action = null)
|
||||||
{
|
{
|
||||||
var normalizedPeople = people.Select(Parser.Parser.Normalize).ToList();
|
var normalizedPeople = people.Select(Parser.Parser.Normalize).ToList();
|
||||||
|
if (normalizedPeople.Count == 0)
|
||||||
|
{
|
||||||
|
var peopleToRemove = existingPeople.Where(p => p.Role == role).ToList();
|
||||||
|
foreach (var existingRoleToRemove in peopleToRemove)
|
||||||
|
{
|
||||||
|
existingPeople.Remove(existingRoleToRemove);
|
||||||
|
action?.Invoke(existingRoleToRemove);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var person in normalizedPeople)
|
foreach (var person in normalizedPeople)
|
||||||
{
|
{
|
||||||
var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName));
|
var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName));
|
||||||
|
|
|
@ -121,7 +121,7 @@ namespace API.Services
|
||||||
return contentType;
|
return contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void UpdateLinks(HtmlNode anchor, Dictionary<string, int> mappings, int currentPage)
|
private static void UpdateLinks(HtmlNode anchor, Dictionary<string, int> mappings, int currentPage)
|
||||||
{
|
{
|
||||||
if (anchor.Name != "a") return;
|
if (anchor.Name != "a") return;
|
||||||
var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty))
|
var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty))
|
||||||
|
@ -278,7 +278,8 @@ namespace API.Services
|
||||||
|
|
||||||
var imageFile = GetKeyForImage(book, image.Attributes[key].Value);
|
var imageFile = GetKeyForImage(book, image.Attributes[key].Value);
|
||||||
image.Attributes.Remove(key);
|
image.Attributes.Remove(key);
|
||||||
image.Attributes.Add(key, $"{apiBase}" + imageFile);
|
// UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx
|
||||||
|
image.Attributes.Add(key, $"{apiBase}" + HttpUtility.UrlEncode(imageFile));
|
||||||
|
|
||||||
// Add a custom class that the reader uses to ensure images stay within reader
|
// Add a custom class that the reader uses to ensure images stay within reader
|
||||||
parent.AddClass("kavita-scale-width-container");
|
parent.AddClass("kavita-scale-width-container");
|
||||||
|
|
|
@ -89,11 +89,10 @@ public class BookmarkService : IBookmarkService
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileInfo = new FileInfo(imageToBookmark);
|
var fileInfo = _directoryService.FileSystem.FileInfo.FromFileName(imageToBookmark);
|
||||||
var bookmarkDirectory =
|
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
|
||||||
var targetFolderStem = BookmarkStem(userWithBookmarks.Id, bookmarkDto.SeriesId, bookmarkDto.ChapterId);
|
var targetFolderStem = BookmarkStem(userWithBookmarks.Id, bookmarkDto.SeriesId, bookmarkDto.ChapterId);
|
||||||
var targetFilepath = Path.Join(bookmarkDirectory, targetFolderStem);
|
var targetFilepath = Path.Join(settings.BookmarksDirectory, targetFolderStem);
|
||||||
|
|
||||||
var bookmark = new AppUserBookmark()
|
var bookmark = new AppUserBookmark()
|
||||||
{
|
{
|
||||||
|
@ -111,8 +110,7 @@ public class BookmarkService : IBookmarkService
|
||||||
_unitOfWork.UserRepository.Update(userWithBookmarks);
|
_unitOfWork.UserRepository.Update(userWithBookmarks);
|
||||||
await _unitOfWork.CommitAsync();
|
await _unitOfWork.CommitAsync();
|
||||||
|
|
||||||
var convertToWebP = bool.Parse((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.ConvertBookmarkToWebP)).Value);
|
if (settings.ConvertBookmarkToWebP)
|
||||||
if (convertToWebP)
|
|
||||||
{
|
{
|
||||||
// Enqueue a task to convert the bookmark to webP
|
// Enqueue a task to convert the bookmark to webP
|
||||||
BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id));
|
BackgroundJob.Enqueue(() => ConvertBookmarkToWebP(bookmark.Id));
|
||||||
|
|
|
@ -48,7 +48,7 @@ public class MetadataService : IMetadataService
|
||||||
private readonly IReadingItemService _readingItemService;
|
private readonly IReadingItemService _readingItemService;
|
||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||||
private IList<SignalRMessage> _updateEvents = new List<SignalRMessage>();
|
private readonly IList<SignalRMessage> _updateEvents = new List<SignalRMessage>();
|
||||||
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
|
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
|
||||||
IEventHub eventHub, ICacheHelper cacheHelper,
|
IEventHub eventHub, ICacheHelper cacheHelper,
|
||||||
IReadingItemService readingItemService, IDirectoryService directoryService)
|
IReadingItemService readingItemService, IDirectoryService directoryService)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
@ -983,9 +984,6 @@ public class ScannerService : IScannerService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (comicInfo.Year > 0)
|
if (comicInfo.Year > 0)
|
||||||
{
|
{
|
||||||
var day = Math.Max(comicInfo.Day, 1);
|
var day = Math.Max(comicInfo.Day, 1);
|
||||||
|
@ -993,104 +991,80 @@ public class ScannerService : IScannerService
|
||||||
chapter.ReleaseDate = DateTime.Parse($"{month}/{day}/{comicInfo.Year}");
|
chapter.ReleaseDate = DateTime.Parse($"{month}/{day}/{comicInfo.Year}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Colorist))
|
var people = GetTagValues(comicInfo.Colorist);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist);
|
||||||
var people = comicInfo.Colorist.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Colorist,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Colorist,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Characters))
|
people = GetTagValues(comicInfo.Characters);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character);
|
||||||
var people = comicInfo.Characters.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Character,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Character,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Translator))
|
|
||||||
{
|
|
||||||
var people = comicInfo.Translator.Split(",");
|
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator);
|
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Tags))
|
people = GetTagValues(comicInfo.Translator);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator);
|
||||||
var tags = comicInfo.Tags.Split(",").Select(s => s.Trim()).ToList();
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Translator,
|
||||||
// Remove all tags that aren't matching between chapter tags and metadata
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList());
|
|
||||||
TagHelper.UpdateTag(allTags, tags, false,
|
|
||||||
(tag, _) =>
|
|
||||||
{
|
|
||||||
chapter.Tags.Add(tag);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Writer))
|
|
||||||
{
|
|
||||||
var people = comicInfo.Writer.Split(",");
|
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer);
|
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Writer,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Editor))
|
people = GetTagValues(comicInfo.Writer);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer);
|
||||||
var people = comicInfo.Editor.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Writer,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Editor,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Inker))
|
people = GetTagValues(comicInfo.Editor);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor);
|
||||||
var people = comicInfo.Inker.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Editor,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Inker,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Letterer))
|
people = GetTagValues(comicInfo.Inker);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker);
|
||||||
var people = comicInfo.Letterer.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Inker,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Letterer,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Penciller))
|
people = GetTagValues(comicInfo.Letterer);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer);
|
||||||
var people = comicInfo.Penciller.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Letterer,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Penciller,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.CoverArtist))
|
|
||||||
{
|
|
||||||
var people = comicInfo.CoverArtist.Split(",");
|
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist);
|
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.CoverArtist,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Publisher))
|
people = GetTagValues(comicInfo.Penciller);
|
||||||
{
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller);
|
||||||
var people = comicInfo.Publisher.Split(",");
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Penciller,
|
||||||
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher);
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher,
|
|
||||||
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(comicInfo.Genre))
|
people = GetTagValues(comicInfo.CoverArtist);
|
||||||
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist);
|
||||||
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.CoverArtist,
|
||||||
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
|
|
||||||
|
people = GetTagValues(comicInfo.Publisher);
|
||||||
|
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher);
|
||||||
|
PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher,
|
||||||
|
person => PersonHelper.AddPersonIfNotExists(chapter.People, person));
|
||||||
|
|
||||||
|
var genres = GetTagValues(comicInfo.Genre);
|
||||||
|
GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList());
|
||||||
|
GenreHelper.UpdateGenre(allGenres, genres, false,
|
||||||
|
genre => chapter.Genres.Add(genre));
|
||||||
|
|
||||||
|
var tags = GetTagValues(comicInfo.Tags);
|
||||||
|
TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList());
|
||||||
|
TagHelper.UpdateTag(allTags, tags, false,
|
||||||
|
(tag, _) =>
|
||||||
|
{
|
||||||
|
chapter.Tags.Add(tag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IList<string> GetTagValues(string comicInfoTagSeparatedByComma)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(comicInfoTagSeparatedByComma))
|
||||||
{
|
{
|
||||||
var genres = comicInfo.Genre.Split(",");
|
return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).ToList();
|
||||||
GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList());
|
|
||||||
GenreHelper.UpdateGenre(allGenres, genres, false,
|
|
||||||
genre => chapter.Genres.Add(genre));
|
|
||||||
}
|
}
|
||||||
|
return ImmutableList<string>.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -103,18 +103,15 @@ public class StatsService : IStatsService
|
||||||
|
|
||||||
public async Task<ServerInfoDto> GetServerInfo()
|
public async Task<ServerInfoDto> GetServerInfo()
|
||||||
{
|
{
|
||||||
var installId = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId);
|
|
||||||
var installVersion = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
|
||||||
|
|
||||||
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
var serverSettings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
|
|
||||||
var serverInfo = new ServerInfoDto
|
var serverInfo = new ServerInfoDto
|
||||||
{
|
{
|
||||||
InstallId = installId.Value,
|
InstallId = serverSettings.InstallId,
|
||||||
Os = RuntimeInformation.OSDescription,
|
Os = RuntimeInformation.OSDescription,
|
||||||
KavitaVersion = installVersion.Value,
|
KavitaVersion = serverSettings.InstallVersion,
|
||||||
DotnetVersion = Environment.Version.ToString(),
|
DotnetVersion = Environment.Version.ToString(),
|
||||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker,
|
IsDocker = new OsInfo().IsDocker,
|
||||||
NumOfCores = Math.Max(Environment.ProcessorCount, 1),
|
NumOfCores = Math.Max(Environment.ProcessorCount, 1),
|
||||||
HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(),
|
HasBookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()).Any(),
|
||||||
NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(),
|
NumberOfLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).Count(),
|
||||||
|
@ -157,22 +154,20 @@ public class StatsService : IStatsService
|
||||||
return _context.SeriesRelation.AnyAsync();
|
return _context.SeriesRelation.AnyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<int> MaxSeriesInAnyLibrary()
|
private async Task<int> MaxSeriesInAnyLibrary()
|
||||||
{
|
{
|
||||||
return _context.Series
|
// If first time flow, just return 0
|
||||||
.Select(s => new
|
if (!await _context.Series.AnyAsync()) return 0;
|
||||||
{
|
return await _context.Series
|
||||||
LibraryId = s.LibraryId,
|
.Select(s => _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series).Count())
|
||||||
Count = _context.Library.Where(l => l.Id == s.LibraryId).SelectMany(l => l.Series).Count()
|
.MaxAsync();
|
||||||
})
|
|
||||||
.AsNoTracking()
|
|
||||||
.AsSplitQuery()
|
|
||||||
.MaxAsync(d => d.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<int> MaxVolumesInASeries()
|
private async Task<int> MaxVolumesInASeries()
|
||||||
{
|
{
|
||||||
return _context.Volume
|
// If first time flow, just return 0
|
||||||
|
if (!await _context.Volume.AnyAsync()) return 0;
|
||||||
|
return await _context.Volume
|
||||||
.Select(v => new
|
.Select(v => new
|
||||||
{
|
{
|
||||||
v.SeriesId,
|
v.SeriesId,
|
||||||
|
@ -183,9 +178,11 @@ public class StatsService : IStatsService
|
||||||
.MaxAsync(d => d.Count);
|
.MaxAsync(d => d.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<int> MaxChaptersInASeries()
|
private async Task<int> MaxChaptersInASeries()
|
||||||
{
|
{
|
||||||
return _context.Series
|
// If first time flow, just return 0
|
||||||
|
if (!await _context.Chapter.AnyAsync()) return 0;
|
||||||
|
return await _context.Series
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.MaxAsync(s => s.Volumes
|
.MaxAsync(s => s.Volumes
|
||||||
|
|
|
@ -83,6 +83,19 @@ namespace Kavita.Common.EnvironmentInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OsInfo()
|
||||||
|
{
|
||||||
|
OsVersionModel osInfo = null;
|
||||||
|
|
||||||
|
Name = Os.ToString();
|
||||||
|
FullName = Name;
|
||||||
|
|
||||||
|
if (IsLinux && File.Exists("/proc/1/cgroup") && File.ReadAllText("/proc/1/cgroup").Contains("/docker/"))
|
||||||
|
{
|
||||||
|
IsDocker = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static Os GetPosixFlavour()
|
private static Os GetPosixFlavour()
|
||||||
{
|
{
|
||||||
var output = RunAndCapture("uname", "-s");
|
var output = RunAndCapture("uname", "-s");
|
||||||
|
|
|
@ -15,10 +15,7 @@ export class CollectionTagService {
|
||||||
constructor(private httpClient: HttpClient, private imageService: ImageService) { }
|
constructor(private httpClient: HttpClient, private imageService: ImageService) { }
|
||||||
|
|
||||||
allTags() {
|
allTags() {
|
||||||
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/').pipe(map(tags => {
|
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/');
|
||||||
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
|
|
||||||
return tags;
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
search(query: string) {
|
search(query: string) {
|
||||||
|
|
83
UI/Web/src/app/_services/jumpbar.service.ts
Normal file
83
UI/Web/src/app/_services/jumpbar.service.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { JumpKey } from '../_models/jumpbar/jump-key';
|
||||||
|
|
||||||
|
const keySize = 25; // Height of the JumpBar button
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class JumpbarService {
|
||||||
|
|
||||||
|
resumeKeys: {[key: string]: string} = {};
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
|
||||||
|
getResumeKey(key: string) {
|
||||||
|
if (this.resumeKeys.hasOwnProperty(key)) return this.resumeKeys[key];
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
saveResumeKey(key: string, value: string) {
|
||||||
|
this.resumeKeys[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateJumpBar(jumpBarKeys: Array<JumpKey>, currentSize: number) {
|
||||||
|
const fullSize = (jumpBarKeys.length * keySize);
|
||||||
|
if (currentSize >= fullSize) {
|
||||||
|
return [...jumpBarKeys];
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpBarKeysToRender: Array<JumpKey> = [];
|
||||||
|
const targetNumberOfKeys = parseInt(Math.floor(currentSize / keySize) + '', 10);
|
||||||
|
const removeCount = jumpBarKeys.length - targetNumberOfKeys - 3;
|
||||||
|
if (removeCount <= 0) return jumpBarKeysToRender;
|
||||||
|
|
||||||
|
const removalTimes = Math.ceil(removeCount / 2);
|
||||||
|
const midPoint = Math.floor(jumpBarKeys.length / 2);
|
||||||
|
jumpBarKeysToRender.push(jumpBarKeys[0]);
|
||||||
|
this.removeFirstPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender);
|
||||||
|
jumpBarKeysToRender.push(jumpBarKeys[midPoint]);
|
||||||
|
this.removeSecondPartOfJumpBar(midPoint, removalTimes, jumpBarKeys, jumpBarKeysToRender);
|
||||||
|
jumpBarKeysToRender.push(jumpBarKeys[jumpBarKeys.length - 1]);
|
||||||
|
|
||||||
|
return jumpBarKeysToRender;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array<JumpKey>, jumpBarKeysToRender: Array<JumpKey>) {
|
||||||
|
const removedIndexes: Array<number> = [];
|
||||||
|
for(let removal = 0; removal < numberOfRemovals; removal++) {
|
||||||
|
let min = 100000000;
|
||||||
|
let minIndex = -1;
|
||||||
|
for(let i = midPoint + 1; i < jumpBarKeys.length - 2; i++) {
|
||||||
|
if (jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
|
||||||
|
min = jumpBarKeys[i].size;
|
||||||
|
minIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removedIndexes.push(minIndex);
|
||||||
|
}
|
||||||
|
for(let i = midPoint + 1; i < jumpBarKeys.length - 2; i++) {
|
||||||
|
if (!removedIndexes.includes(i)) jumpBarKeysToRender.push(jumpBarKeys[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1, jumpBarKeys: Array<JumpKey>, jumpBarKeysToRender: Array<JumpKey>) {
|
||||||
|
const removedIndexes: Array<number> = [];
|
||||||
|
for(let removal = 0; removal < numberOfRemovals; removal++) {
|
||||||
|
let min = 100000000;
|
||||||
|
let minIndex = -1;
|
||||||
|
for(let i = 1; i < midPoint; i++) {
|
||||||
|
if (jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
|
||||||
|
min = jumpBarKeys[i].size;
|
||||||
|
minIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removedIndexes.push(minIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(let i = 1; i < midPoint; i++) {
|
||||||
|
if (!removedIndexes.includes(i)) jumpBarKeysToRender.push(jumpBarKeys[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,27 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { ElementRef, Injectable } from '@angular/core';
|
||||||
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
|
import { filter, ReplaySubject } from 'rxjs';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class ScrollService {
|
export class ScrollService {
|
||||||
|
|
||||||
constructor() { }
|
private scrollContainerSource = new ReplaySubject<string | ElementRef<HTMLElement>>(1);
|
||||||
|
/**
|
||||||
|
* Exposes the current container on the active screen that is our primary overlay area. Defaults to 'body' and changes to 'body' on page loads
|
||||||
|
*/
|
||||||
|
public scrollContainer$ = this.scrollContainerSource.asObservable();
|
||||||
|
|
||||||
|
constructor(router: Router) {
|
||||||
|
|
||||||
|
router.events
|
||||||
|
.pipe(filter(event => event instanceof NavigationEnd))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.scrollContainerSource.next('body');
|
||||||
|
});
|
||||||
|
this.scrollContainerSource.next('body');
|
||||||
|
}
|
||||||
|
|
||||||
get scrollPosition() {
|
get scrollPosition() {
|
||||||
return (window.pageYOffset
|
return (window.pageYOffset
|
||||||
|
@ -26,4 +42,10 @@ export class ScrollService {
|
||||||
behavior: 'auto'
|
behavior: 'auto'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setScrollContainer(elem: ElementRef<HTMLElement> | undefined) {
|
||||||
|
if (elem !== undefined) {
|
||||||
|
this.scrollContainerSource.next(elem);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,7 +108,7 @@
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.Files]">
|
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="!(isAdmin$ | async)">
|
||||||
<a ngbNavLink>{{tabs[TabID.Files].title}}</a>
|
<a ngbNavLink>{{tabs[TabID.Files].title}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
|
||||||
|
|
|
@ -30,7 +30,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="jumpBarKeysToRender.length >= 4" [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
|
<ng-container *ngIf="jumpBarKeysToRender.length >= 4 && scroll.viewPortInfo.maxScrollPosition > 0" [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
<ng-template #cardTemplate>
|
<ng-template #cardTemplate>
|
||||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
|
import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
|
||||||
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
|
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
|
||||||
import { Subject } from 'rxjs';
|
import { first, Subject, takeUntil, takeWhile } from 'rxjs';
|
||||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||||
|
@ -10,9 +10,10 @@ import { Library } from 'src/app/_models/library';
|
||||||
import { Pagination } from 'src/app/_models/pagination';
|
import { Pagination } from 'src/app/_models/pagination';
|
||||||
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
|
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
|
||||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||||
|
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
||||||
import { SeriesService } from 'src/app/_services/series.service';
|
import { SeriesService } from 'src/app/_services/series.service';
|
||||||
|
|
||||||
const keySize = 24;
|
const keySize = 25; // Height of the JumpBar button
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-card-detail-layout',
|
selector: 'app-card-detail-layout',
|
||||||
|
@ -20,7 +21,7 @@ const keySize = 24;
|
||||||
styleUrls: ['./card-detail-layout.component.scss'],
|
styleUrls: ['./card-detail-layout.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges, AfterViewInit {
|
||||||
|
|
||||||
@Input() header: string = '';
|
@Input() header: string = '';
|
||||||
@Input() isLoading: boolean = false;
|
@Input() isLoading: boolean = false;
|
||||||
|
@ -62,6 +63,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
libraries: Array<FilterItem<Library>> = [];
|
libraries: Array<FilterItem<Library>> = [];
|
||||||
|
|
||||||
updateApplied: number = 0;
|
updateApplied: number = 0;
|
||||||
|
hasResumedJumpKey: boolean = false;
|
||||||
|
|
||||||
private onDestory: Subject<void> = new Subject();
|
private onDestory: Subject<void> = new Subject();
|
||||||
|
|
||||||
|
@ -70,7 +72,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private seriesService: SeriesService, public utilityService: UtilityService,
|
constructor(private seriesService: SeriesService, public utilityService: UtilityService,
|
||||||
@Inject(DOCUMENT) private document: Document, private changeDetectionRef: ChangeDetectorRef) {
|
@Inject(DOCUMENT) private document: Document, private changeDetectionRef: ChangeDetectorRef,
|
||||||
|
private jumpbarService: JumpbarService) {
|
||||||
this.filter = this.seriesService.createSeriesFilter();
|
this.filter = this.seriesService.createSeriesFilter();
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
@ -78,74 +81,16 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
@HostListener('window:orientationchange', ['$event'])
|
@HostListener('window:orientationchange', ['$event'])
|
||||||
resizeJumpBar() {
|
resizeJumpBar() {
|
||||||
const fullSize = (this.jumpBarKeys.length * keySize);
|
|
||||||
const currentSize = (this.document.querySelector('.viewport-container')?.getBoundingClientRect().height || 10) - 30;
|
const currentSize = (this.document.querySelector('.viewport-container')?.getBoundingClientRect().height || 10) - 30;
|
||||||
if (currentSize >= fullSize) {
|
this.jumpBarKeysToRender = this.jumpbarService.generateJumpBar(this.jumpBarKeys, currentSize);
|
||||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
|
||||||
this.changeDetectionRef.markForCheck();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetNumberOfKeys = parseInt(Math.floor(currentSize / keySize) + '', 10);
|
|
||||||
const removeCount = this.jumpBarKeys.length - targetNumberOfKeys - 3;
|
|
||||||
if (removeCount <= 0) return;
|
|
||||||
|
|
||||||
|
|
||||||
this.jumpBarKeysToRender = [];
|
|
||||||
|
|
||||||
const removalTimes = Math.ceil(removeCount / 2);
|
|
||||||
const midPoint = Math.floor(this.jumpBarKeys.length / 2);
|
|
||||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[0]);
|
|
||||||
this.removeFirstPartOfJumpBar(midPoint, removalTimes);
|
|
||||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[midPoint]);
|
|
||||||
this.removeSecondPartOfJumpBar(midPoint, removalTimes);
|
|
||||||
this.jumpBarKeysToRender.push(this.jumpBarKeys[this.jumpBarKeys.length - 1]);
|
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
|
|
||||||
const removedIndexes: Array<number> = [];
|
|
||||||
for(let removal = 0; removal < numberOfRemovals; removal++) {
|
|
||||||
let min = 100000000;
|
|
||||||
let minIndex = -1;
|
|
||||||
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
|
|
||||||
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
|
|
||||||
min = this.jumpBarKeys[i].size;
|
|
||||||
minIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removedIndexes.push(minIndex);
|
|
||||||
}
|
|
||||||
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
|
|
||||||
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
|
|
||||||
const removedIndexes: Array<number> = [];
|
|
||||||
for(let removal = 0; removal < numberOfRemovals; removal++) {
|
|
||||||
let min = 100000000;
|
|
||||||
let minIndex = -1;
|
|
||||||
for(let i = 1; i < midPoint; i++) {
|
|
||||||
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
|
|
||||||
min = this.jumpBarKeys[i].size;
|
|
||||||
minIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
removedIndexes.push(minIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(let i = 1; i < midPoint; i++) {
|
|
||||||
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.trackByIdentity === undefined) {
|
if (this.trackByIdentity === undefined) {
|
||||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
|
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (this.filterSettings === undefined) {
|
if (this.filterSettings === undefined) {
|
||||||
this.filterSettings = new FilterSettings();
|
this.filterSettings = new FilterSettings();
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
|
@ -157,9 +102,27 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngAfterViewInit(): void {
|
||||||
|
// NOTE: I can't seem to figure out a way to resume the JumpKey with the scroller.
|
||||||
|
// this.virtualScroller.vsUpdate.pipe(takeWhile(() => this.hasResumedJumpKey), takeUntil(this.onDestory)).subscribe(() => {
|
||||||
|
// const resumeKey = this.jumpbarService.getResumeKey(this.header);
|
||||||
|
// console.log('Resume key:', resumeKey);
|
||||||
|
// if (resumeKey !== '') {
|
||||||
|
// const keys = this.jumpBarKeys.filter(k => k.key === resumeKey);
|
||||||
|
// if (keys.length >= 1) {
|
||||||
|
// console.log('Scrolling to ', keys[0].key);
|
||||||
|
// this.scrollTo(keys[0]);
|
||||||
|
// this.hasResumedJumpKey = true;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// this.hasResumedJumpKey = true;
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
||||||
this.resizeJumpBar();
|
this.resizeJumpBar();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -188,7 +151,8 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
targetIndex += this.jumpBarKeys[i].size;
|
targetIndex += this.jumpBarKeys[i].size;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.virtualScroller.scrollToIndex(targetIndex, true, undefined, 1000);
|
this.virtualScroller.scrollToIndex(targetIndex, true, 800, 1000);
|
||||||
|
this.jumpbarService.saveResumeKey(this.header, jumpKey.key);
|
||||||
this.changeDetectionRef.markForCheck();
|
this.changeDetectionRef.markForCheck();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -177,6 +177,33 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||||
if (this.utilityService.isVolume(this.entity) && updateEvent.volumeId !== this.entity.id) return;
|
if (this.utilityService.isVolume(this.entity) && updateEvent.volumeId !== this.entity.id) return;
|
||||||
if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return;
|
if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return;
|
||||||
|
|
||||||
|
// For volume or Series, we can't just take the event
|
||||||
|
if (this.utilityService.isVolume(this.entity) || this.utilityService.isSeries(this.entity)) {
|
||||||
|
if (this.utilityService.isVolume(this.entity)) {
|
||||||
|
const v = this.utilityService.asVolume(this.entity);
|
||||||
|
const chapter = v.chapters.find(c => c.id === updateEvent.chapterId);
|
||||||
|
if (chapter) {
|
||||||
|
chapter.pagesRead = updateEvent.pagesRead;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// re-request progress for the series
|
||||||
|
const s = this.utilityService.asSeries(this.entity);
|
||||||
|
let pagesRead = 0;
|
||||||
|
if (s.hasOwnProperty('volumes')) {
|
||||||
|
s.volumes.forEach(v => {
|
||||||
|
v.chapters.forEach(c => {
|
||||||
|
if (c.id === updateEvent.chapterId) {
|
||||||
|
c.pagesRead = updateEvent.pagesRead;
|
||||||
|
}
|
||||||
|
pagesRead += c.pagesRead;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
s.pagesRead = pagesRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
this.read = updateEvent.pagesRead;
|
this.read = updateEvent.pagesRead;
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock>
|
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block" *ngIf="collectionTag.coverImage !== '' && collectionTag.coverImage !== undefined && collectionTag.coverImage !== null">
|
||||||
<app-image maxWidth="481px" [imageUrl]="tagImage"></app-image>
|
<app-image maxWidth="481px" [imageUrl]="tagImage"></app-image>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { Router, ActivatedRoute } from '@angular/router';
|
import { Router, ActivatedRoute } from '@angular/router';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
@ -22,6 +22,7 @@ import { ActionService } from 'src/app/_services/action.service';
|
||||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||||
import { ImageService } from 'src/app/_services/image.service';
|
import { ImageService } from 'src/app/_services/image.service';
|
||||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||||
|
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||||
import { SeriesService } from 'src/app/_services/series.service';
|
import { SeriesService } from 'src/app/_services/series.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -30,7 +31,7 @@ import { SeriesService } from 'src/app/_services/series.service';
|
||||||
styleUrls: ['./collection-detail.component.scss'],
|
styleUrls: ['./collection-detail.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CollectionDetailComponent implements OnInit, OnDestroy {
|
export class CollectionDetailComponent implements OnInit, OnDestroy, AfterContentChecked {
|
||||||
|
|
||||||
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
||||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||||
|
@ -113,7 +114,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
||||||
private modalService: NgbModal, private titleService: Title,
|
private modalService: NgbModal, private titleService: Title,
|
||||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
|
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
|
||||||
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
|
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
|
||||||
private readonly cdRef: ChangeDetectorRef) {
|
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) {
|
||||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||||
|
|
||||||
const routeId = this.route.snapshot.paramMap.get('id');
|
const routeId = this.route.snapshot.paramMap.get('id');
|
||||||
|
@ -148,6 +149,10 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngAfterContentChecked(): void {
|
||||||
|
this.scrollService.setScrollContainer(this.scrollingBlock);
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.onDestory.next();
|
this.onDestory.next();
|
||||||
this.onDestory.complete();
|
this.onDestory.complete();
|
||||||
|
@ -230,6 +235,8 @@ export class CollectionDetailComponent implements OnInit, OnDestroy {
|
||||||
this.loadPage();
|
this.loadPage();
|
||||||
if (results.coverImageUpdated) {
|
if (results.coverImageUpdated) {
|
||||||
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
|
this.tagImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
|
||||||
|
this.collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -611,7 +611,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
} else if (event.key === KEY_CODES.B) {
|
} else if (event.key === KEY_CODES.B) {
|
||||||
this.bookmarkPage();
|
this.bookmarkPage();
|
||||||
} else if (event.key === KEY_CODES.F) {
|
} else if (event.key === KEY_CODES.F) {
|
||||||
this.toggleFullscreen()
|
this.toggleFullscreen();
|
||||||
|
} else if (event.key === KEY_CODES.H) {
|
||||||
|
this.openShortcutModal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
|
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
|
||||||
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
|
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input #input [id]="id" type="search" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
|
<input #input [id]="id" type="text" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
|
||||||
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
|
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
|
||||||
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="search"
|
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="search"
|
||||||
>
|
>
|
||||||
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
|
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="resetField()">
|
<button type="button" class="btn-close" aria-label="Close" (click)="resetField()" *ngIf="typeaheadForm.get('typeahead')?.value.length > 0"></button>
|
||||||
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown" *ngIf="hasFocus">
|
<div class="dropdown" *ngIf="hasFocus">
|
||||||
|
|
|
@ -129,8 +129,8 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<ng-container *ngIf="!searchFocused">
|
<ng-container *ngIf="!searchFocused">
|
||||||
<div class="back-to-top">
|
<div class="back-to-top" *ngIf="backToTopNeeded">
|
||||||
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
|
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()">
|
||||||
<i class="fa fa-angle-double-up nav" aria-hidden="true"></i>
|
<i class="fa fa-angle-double-up nav" aria-hidden="true"></i>
|
||||||
<span class="visually-hidden">Scroll to Top</span>
|
<span class="visually-hidden">Scroll to Top</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
import { Subject } from 'rxjs';
|
import { fromEvent, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged, filter, takeUntil, takeWhile, tap } from 'rxjs/operators';
|
||||||
import { Chapter } from 'src/app/_models/chapter';
|
import { Chapter } from 'src/app/_models/chapter';
|
||||||
import { MangaFile } from 'src/app/_models/manga-file';
|
import { MangaFile } from 'src/app/_models/manga-file';
|
||||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||||
|
@ -48,44 +48,36 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
backToTopNeeded = false;
|
backToTopNeeded = false;
|
||||||
searchFocused: boolean = false;
|
searchFocused: boolean = false;
|
||||||
|
scrollElem: HTMLElement;
|
||||||
private readonly onDestroy = new Subject<void>();
|
private readonly onDestroy = new Subject<void>();
|
||||||
|
|
||||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||||
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||||
private scrollService: ScrollService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) { }
|
private scrollService: ScrollService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {
|
||||||
|
this.scrollElem = this.document.body;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// setTimeout(() => this.setupScrollChecker(), 1000);
|
this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntil(this.onDestroy), tap((scrollContainer) => {
|
||||||
// // TODO: on router change, reset the scroll check
|
if (scrollContainer === 'body' || scrollContainer === undefined) {
|
||||||
|
this.scrollElem = this.document.body;
|
||||||
// this.router.events
|
fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.document.body));
|
||||||
// .pipe(filter(event => event instanceof NavigationStart))
|
} else {
|
||||||
// .subscribe((event) => {
|
const elem = scrollContainer as ElementRef<HTMLDivElement>;
|
||||||
// setTimeout(() => this.setupScrollChecker(), 1000);
|
this.scrollElem = elem.nativeElement;
|
||||||
// });
|
fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement));
|
||||||
|
}
|
||||||
|
})).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupScrollChecker() {
|
checkBackToTopNeeded(elem: HTMLElement) {
|
||||||
// const viewportScroller = this.document.querySelector('.viewport-container');
|
const offset = elem.scrollTop || 0;
|
||||||
// console.log('viewport container', viewportScroller);
|
|
||||||
|
|
||||||
// if (viewportScroller) {
|
|
||||||
// fromEvent(viewportScroller, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded());
|
|
||||||
// } else {
|
|
||||||
// fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded());
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
@HostListener('body:scroll', [])
|
|
||||||
checkBackToTopNeeded() {
|
|
||||||
// TODO: This somehow needs to hook into the scrolling for virtual scroll
|
|
||||||
|
|
||||||
const offset = this.scrollService.scrollPosition;
|
|
||||||
if (offset > 100) {
|
if (offset > 100) {
|
||||||
this.backToTopNeeded = true;
|
this.backToTopNeeded = true;
|
||||||
} else if (offset < 40) {
|
} else if (offset < 40) {
|
||||||
this.backToTopNeeded = false;
|
this.backToTopNeeded = false;
|
||||||
}
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
@ -219,7 +211,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
|
||||||
scrollToTop() {
|
scrollToTop() {
|
||||||
this.scrollService.scrollTo(0, this.document.body);
|
this.scrollService.scrollTo(0, this.scrollElem);
|
||||||
}
|
}
|
||||||
|
|
||||||
focusUpdate(searchFocused: boolean) {
|
focusUpdate(searchFocused: boolean) {
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
<div class="container-fluid mt-2" *ngIf="readingList">
|
<div class="container-fluid mt-2" *ngIf="readingList">
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block" *ngIf="readingList.coverImage !== '' || readingList.coverImage !== undefined">
|
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block" *ngIf="readingList.coverImage !== '' && readingList.coverImage !== undefined && readingList.coverImage !== null">
|
||||||
<app-image maxWidth="300px" maxHeight="400px" [imageUrl]="readingListImage"></app-image>
|
<app-image maxWidth="300px" maxHeight="400px" [imageUrl]="readingListImage"></app-image>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||||
|
@ -45,7 +45,9 @@
|
||||||
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
|
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
<div class="mx-auto" style="width: 200px;">
|
<div class="mx-auto" style="width: 200px;">
|
||||||
<ng-container *ngIf="items.length === 0 && !isLoading">
|
<ng-container *ngIf="items.length === 0 && !isLoading">
|
||||||
Nothing added
|
Nothing added
|
||||||
|
@ -64,4 +66,5 @@
|
||||||
[promoted]="item.promoted" (read)="readChapter($event)"></app-reading-list-item>
|
[promoted]="item.promoted" (read)="readChapter($event)"></app-reading-list-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-draggable-ordered-list>
|
</app-draggable-ordered-list>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
|
@ -1,4 +1,4 @@
|
||||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, Inject, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
|
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, Inject, ChangeDetectionStrategy, ChangeDetectorRef, AfterContentChecked, AfterViewInit } from '@angular/core';
|
||||||
import { Title } from '@angular/platform-browser';
|
import { Title } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
@ -40,6 +40,7 @@ import { PageLayoutMode } from '../_models/page-layout-mode';
|
||||||
import { DOCUMENT } from '@angular/common';
|
import { DOCUMENT } from '@angular/common';
|
||||||
import { User } from '../_models/user';
|
import { User } from '../_models/user';
|
||||||
import { Download } from '../shared/_models/download';
|
import { Download } from '../shared/_models/download';
|
||||||
|
import { ScrollService } from '../_services/scroll.service';
|
||||||
|
|
||||||
interface RelatedSeris {
|
interface RelatedSeris {
|
||||||
series: Series;
|
series: Series;
|
||||||
|
@ -66,7 +67,7 @@ interface StoryLineItem {
|
||||||
styleUrls: ['./series-detail.component.scss'],
|
styleUrls: ['./series-detail.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class SeriesDetailComponent implements OnInit, OnDestroy {
|
export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChecked {
|
||||||
|
|
||||||
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
||||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||||
|
@ -250,7 +251,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||||
public imageSerivce: ImageService, private messageHub: MessageHubService,
|
public imageSerivce: ImageService, private messageHub: MessageHubService,
|
||||||
private readingListService: ReadingListService, public navService: NavService,
|
private readingListService: ReadingListService, public navService: NavService,
|
||||||
private offcanvasService: NgbOffcanvas, @Inject(DOCUMENT) private document: Document,
|
private offcanvasService: NgbOffcanvas, @Inject(DOCUMENT) private document: Document,
|
||||||
private changeDetectionRef: ChangeDetectorRef
|
private changeDetectionRef: ChangeDetectorRef, private scrollService: ScrollService
|
||||||
) {
|
) {
|
||||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||||
|
@ -265,6 +266,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngAfterContentChecked(): void {
|
||||||
|
this.scrollService.setScrollContainer(this.scrollingBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const routeId = this.route.snapshot.paramMap.get('seriesId');
|
const routeId = this.route.snapshot.paramMap.get('seriesId');
|
||||||
|
|
|
@ -18,6 +18,7 @@ export enum KEY_CODES {
|
||||||
G = 'g',
|
G = 'g',
|
||||||
B = 'b',
|
B = 'b',
|
||||||
F = 'f',
|
F = 'f',
|
||||||
|
H = 'h',
|
||||||
BACKSPACE = 'Backspace',
|
BACKSPACE = 'Backspace',
|
||||||
DELETE = 'Delete',
|
DELETE = 'Delete',
|
||||||
SHIFT = 'Shift'
|
SHIFT = 'Shift'
|
||||||
|
|
|
@ -10,7 +10,6 @@
|
||||||
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i }"></ng-container>
|
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i }"></ng-container>
|
||||||
<i class="fa fa-times" *ngIf="!disabled" (click)="toggleSelection(option)" tabindex="0" aria-label="close"></i>
|
<i class="fa fa-times" *ngIf="!disabled" (click)="toggleSelection(option)" tabindex="0" aria-label="close"></i>
|
||||||
</app-tag-badge>
|
</app-tag-badge>
|
||||||
|
|
||||||
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead" *ngIf="!disabled">
|
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead" *ngIf="!disabled">
|
||||||
<div class="spinner-border spinner-border-sm {{settings.multiple ? 'close-offset' : ''}}" role="status" *ngIf="isLoadingOptions">
|
<div class="spinner-border spinner-border-sm {{settings.multiple ? 'close-offset' : ''}}" role="status" *ngIf="isLoadingOptions">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
|
|
@ -286,8 +286,12 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@HostListener('window:click', ['$event'])
|
@HostListener('body:click', ['$event'])
|
||||||
handleDocumentClick(event: any) {
|
handleDocumentClick(event: any) {
|
||||||
|
// Don't close the typeahead when we select an item from it
|
||||||
|
if (event.target && (event.target as HTMLElement).classList.contains('list-group-item')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.hasFocus = false;
|
this.hasFocus = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,7 +335,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||||
case KEY_CODES.DELETE:
|
case KEY_CODES.DELETE:
|
||||||
{
|
{
|
||||||
if (this.typeaheadControl.value !== null && this.typeaheadControl.value !== undefined && this.typeaheadControl.value.trim() !== '') {
|
if (this.typeaheadControl.value !== null && this.typeaheadControl.value !== undefined && this.typeaheadControl.value.trim() !== '') {
|
||||||
return;
|
break;
|
||||||
}
|
}
|
||||||
const selected = this.optionSelection.selected();
|
const selected = this.optionSelection.selected();
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
|
@ -364,12 +368,14 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||||
if (!untoggleAll && this.settings.savedData) {
|
if (!untoggleAll && this.settings.savedData) {
|
||||||
const isArray = this.settings.savedData.hasOwnProperty('length');
|
const isArray = this.settings.savedData.hasOwnProperty('length');
|
||||||
if (isArray) {
|
if (isArray) {
|
||||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData); // NOTE: Library-detail will break the 'x' button due to how savedData is being set to avoid state reset
|
||||||
} else {
|
} else {
|
||||||
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
|
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
|
||||||
}
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
} else {
|
} else {
|
||||||
this.optionSelection.selected().forEach(item => this.optionSelection.toggle(item, false));
|
this.optionSelection.selected().forEach(item => this.optionSelection.toggle(item, false));
|
||||||
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedData.emit(this.optionSelection.selected());
|
this.selectedData.emit(this.optionSelection.selected());
|
||||||
|
@ -386,7 +392,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||||
this.toggleSelection(opt);
|
this.toggleSelection(opt);
|
||||||
|
|
||||||
this.resetField();
|
this.resetField();
|
||||||
this.onInputFocus(undefined);
|
this.onInputFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
addNewItem(title: string) {
|
addNewItem(title: string) {
|
||||||
|
@ -398,7 +404,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||||
this.toggleSelection(newItem);
|
this.toggleSelection(newItem);
|
||||||
|
|
||||||
this.resetField();
|
this.resetField();
|
||||||
this.onInputFocus(undefined);
|
this.onInputFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -421,7 +427,7 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputFocus(event: any) {
|
onInputFocus(event?: any) {
|
||||||
if (event) {
|
if (event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue