mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
A boatload of Bugs (#3704)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
parent
ea9b7ad0d1
commit
37734554ba
@ -71,6 +71,7 @@ public class MangaParsingTests
|
||||
[InlineData("죽음 13회", "13")]
|
||||
[InlineData("동의보감 13장", "13")]
|
||||
[InlineData("몰?루 아카이브 7.5권", "7.5")]
|
||||
[InlineData("주술회전 1.5권", "1.5")]
|
||||
[InlineData("63권#200", "63")]
|
||||
[InlineData("시즌34삽화2", "34")]
|
||||
[InlineData("Accel World Chapter 001 Volume 002", "2")]
|
||||
|
@ -50,7 +50,7 @@ public class BookController : BaseApiController
|
||||
case MangaFormat.Epub:
|
||||
{
|
||||
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId))[0];
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
|
||||
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.LenientBookReaderOptions);
|
||||
bookTitle = book.Title;
|
||||
break;
|
||||
}
|
||||
@ -102,7 +102,7 @@ public class BookController : BaseApiController
|
||||
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
|
||||
if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
|
||||
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions);
|
||||
var key = BookService.CoalesceKeyForAnyFile(book, file);
|
||||
|
||||
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing"));
|
||||
|
@ -12,6 +12,7 @@ using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
@ -24,11 +25,16 @@ public class FilterController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IStreamService _streamService;
|
||||
private readonly ILogger<FilterController> _logger;
|
||||
|
||||
public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService)
|
||||
public FilterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IStreamService streamService,
|
||||
ILogger<FilterController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
_streamService = streamService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -120,4 +126,57 @@ public class FilterController : BaseApiController
|
||||
{
|
||||
return Ok(SmartFilterHelper.Decode(dto.EncodedFilter));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rename a Smart Filter given the filterId and new name
|
||||
/// </summary>
|
||||
/// <param name="filterId"></param>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("rename")]
|
||||
public async Task<ActionResult> RenameFilter([FromQuery] int filterId, [FromQuery] string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(),
|
||||
AppUserIncludes.SmartFilters);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
name = name.Trim();
|
||||
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(user.Id, "permission-denied"));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-name-required"));
|
||||
}
|
||||
|
||||
if (Seed.DefaultStreams.Any(s => s.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)))
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(user.Id, "smart-filter-system-name"));
|
||||
}
|
||||
|
||||
var filter = user.SmartFilters.FirstOrDefault(f => f.Id == filterId);
|
||||
if (filter == null)
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(user.Id, "filter-not-found"));
|
||||
}
|
||||
|
||||
filter.Name = name;
|
||||
_unitOfWork.AppUserSmartFilterRepository.Update(filter);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
await _streamService.RenameSmartFilterStreams(filter);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when renaming smart filter: {FilterId}", filterId);
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -204,4 +204,30 @@ public class StreamController : BaseApiController
|
||||
await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a Smart Filter from a user's SideNav Streams
|
||||
/// </summary>
|
||||
/// <param name="sideNavStreamId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete("smart-filter-side-nav-stream")]
|
||||
public async Task<ActionResult> DeleteSmartFilterSideNavStream([FromQuery] int sideNavStreamId)
|
||||
{
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
|
||||
await _streamService.DeleteSideNavSmartFilterStream(User.GetUserId(), sideNavStreamId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a Smart Filter from a user's Dashboard Streams
|
||||
/// </summary>
|
||||
/// <param name="dashboardStreamId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete("smart-filter-dashboard-stream")]
|
||||
public async Task<ActionResult> DeleteSmartFilterDashboardStream([FromQuery] int dashboardStreamId)
|
||||
{
|
||||
if (User.IsInRole(PolicyConstants.ReadOnlyRole)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "permission-denied"));
|
||||
await _streamService.DeleteDashboardSmartFilterStream(User.GetUserId(), dashboardStreamId);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
@ -15,10 +15,12 @@ public interface IMediaErrorRepository
|
||||
{
|
||||
void Attach(MediaError error);
|
||||
void Remove(MediaError error);
|
||||
void Remove(IList<MediaError> errors);
|
||||
Task<MediaError> Find(string filename);
|
||||
IEnumerable<MediaErrorDto> GetAllErrorDtosAsync();
|
||||
Task<bool> ExistsAsync(MediaError error);
|
||||
Task DeleteAll();
|
||||
Task<List<MediaError>> GetAllErrorsAsync(IList<string> comments);
|
||||
}
|
||||
|
||||
public class MediaErrorRepository : IMediaErrorRepository
|
||||
@ -44,6 +46,11 @@ public class MediaErrorRepository : IMediaErrorRepository
|
||||
_context.MediaError.Remove(error);
|
||||
}
|
||||
|
||||
public void Remove(IList<MediaError> errors)
|
||||
{
|
||||
_context.MediaError.RemoveRange(errors);
|
||||
}
|
||||
|
||||
public Task<MediaError?> Find(string filename)
|
||||
{
|
||||
return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync();
|
||||
@ -71,4 +78,11 @@ public class MediaErrorRepository : IMediaErrorRepository
|
||||
_context.MediaError.RemoveRange(await _context.MediaError.ToListAsync());
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task<List<MediaError>> GetAllErrorsAsync(IList<string> comments)
|
||||
{
|
||||
return _context.MediaError
|
||||
.Where(m => comments.Contains(m.Comment))
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
@ -57,7 +57,9 @@ public interface IUserRepository
|
||||
void Delete(AppUser? user);
|
||||
void Delete(AppUserBookmark bookmark);
|
||||
void Delete(IEnumerable<AppUserDashboardStream> streams);
|
||||
void Delete(AppUserDashboardStream stream);
|
||||
void Delete(IEnumerable<AppUserSideNavStream> streams);
|
||||
void Delete(AppUserSideNavStream stream);
|
||||
Task<IEnumerable<MemberDto>> GetEmailConfirmedMemberDtosAsync(bool emailConfirmed = true);
|
||||
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
|
||||
Task<bool> IsUserAdminAsync(AppUser? user);
|
||||
@ -95,6 +97,7 @@ public interface IUserRepository
|
||||
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
|
||||
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
|
||||
Task<AppUserSideNavStream?> GetSideNavStream(int streamId);
|
||||
Task<AppUserSideNavStream?> GetSideNavStreamWithUser(int streamId);
|
||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
|
||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
|
||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
|
||||
@ -167,11 +170,21 @@ public class UserRepository : IUserRepository
|
||||
_context.AppUserDashboardStream.RemoveRange(streams);
|
||||
}
|
||||
|
||||
public void Delete(AppUserDashboardStream stream)
|
||||
{
|
||||
_context.AppUserDashboardStream.Remove(stream);
|
||||
}
|
||||
|
||||
public void Delete(IEnumerable<AppUserSideNavStream> streams)
|
||||
{
|
||||
_context.AppUserSideNavStream.RemoveRange(streams);
|
||||
}
|
||||
|
||||
public void Delete(AppUserSideNavStream stream)
|
||||
{
|
||||
_context.AppUserSideNavStream.Remove(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A one stop shop to get a tracked AppUser instance with any number of JOINs generated by passing bitwise flags.
|
||||
/// </summary>
|
||||
@ -396,6 +409,7 @@ public class UserRepository : IUserRepository
|
||||
.FirstOrDefaultAsync(d => d.Id == streamId);
|
||||
}
|
||||
|
||||
|
||||
public async Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
@ -432,10 +446,10 @@ public class UserRepository : IUserRepository
|
||||
.Select(d => d.LibraryId)
|
||||
.ToList();
|
||||
|
||||
var libraryDtos = _context.Library
|
||||
var libraryDtos = await _context.Library
|
||||
.Where(l => libraryIds.Contains(l.Id))
|
||||
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
|
||||
.ToList();
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var dto in sideNavStreams.Where(dto => dto.StreamType == SideNavStreamType.Library))
|
||||
{
|
||||
@ -459,13 +473,21 @@ public class UserRepository : IUserRepository
|
||||
return sideNavStreams;
|
||||
}
|
||||
|
||||
public async Task<AppUserSideNavStream> GetSideNavStream(int streamId)
|
||||
public async Task<AppUserSideNavStream?> GetSideNavStream(int streamId)
|
||||
{
|
||||
return await _context.AppUserSideNavStream
|
||||
.Include(d => d.SmartFilter)
|
||||
.FirstOrDefaultAsync(d => d.Id == streamId);
|
||||
}
|
||||
|
||||
public async Task<AppUserSideNavStream?> GetSideNavStreamWithUser(int streamId)
|
||||
{
|
||||
return await _context.AppUserSideNavStream
|
||||
.Include(d => d.SmartFilter)
|
||||
.Include(d => d.AppUser)
|
||||
.FirstOrDefaultAsync(d => d.Id == streamId);
|
||||
}
|
||||
|
||||
public async Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId)
|
||||
{
|
||||
return await _context.AppUserSideNavStream
|
||||
|
@ -121,6 +121,8 @@ public class SeriesMetadata : IHasConcurrencyToken
|
||||
/// <returns></returns>
|
||||
public bool AllKavitaPlus(PersonRole role)
|
||||
{
|
||||
return People.Where(p => p.Role == role).All(p => p.KavitaPlusConnection);
|
||||
var people = People.Where(p => p.Role == role);
|
||||
if (people.Any()) return people.All(p => p.KavitaPlusConnection);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.IO;
|
||||
using API.Entities;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
@ -12,7 +13,7 @@ public class MediaErrorBuilder : IEntityBuilder<MediaError>
|
||||
{
|
||||
_mediaError = new MediaError()
|
||||
{
|
||||
FilePath = filePath,
|
||||
FilePath = Parser.NormalizePath(filePath),
|
||||
Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant()
|
||||
};
|
||||
}
|
||||
|
@ -18,14 +18,18 @@ public static class JwtHelper
|
||||
// Parse the JWT and extract the expiry claim
|
||||
var jwtHandler = new JwtSecurityTokenHandler();
|
||||
var token = jwtHandler.ReadJwtToken(jwtToken);
|
||||
var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value;
|
||||
return token.ValidTo;
|
||||
|
||||
if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds))
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
// var exp = token.Claims.FirstOrDefault(c => c.Type == "exp")?.Value;
|
||||
//
|
||||
// if (long.TryParse(exp, CultureInfo.InvariantCulture, out var expSeconds))
|
||||
// {
|
||||
// return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcDateTime;
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// return DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -186,6 +186,10 @@
|
||||
"external-source-required": "ApiKey and Host required",
|
||||
"external-source-doesnt-exist": "External Source doesn't exist",
|
||||
"external-source-already-in-use": "There is an existing stream with this External Source",
|
||||
"sidenav-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the SideNav",
|
||||
"dashboard-stream-only-delete-smart-filter": "Only smart filter streams can be deleted from the dashboard",
|
||||
"smart-filter-name-required": "Smart Filter name required",
|
||||
"smart-filter-system-name": "You cannot use the name of a system provided stream",
|
||||
|
||||
"not-authenticated": "User is not authenticated",
|
||||
"unable-to-register-k+": "Unable to register license due to error. Reach out to Kavita+ Support",
|
||||
|
@ -85,11 +85,32 @@ public class BookService : IBookService
|
||||
},
|
||||
Epub2NcxReaderOptions = new Epub2NcxReaderOptions
|
||||
{
|
||||
IgnoreMissingContentForNavigationPoints = true
|
||||
IgnoreMissingContentForNavigationPoints = false
|
||||
},
|
||||
SpineReaderOptions = new SpineReaderOptions
|
||||
{
|
||||
IgnoreMissingManifestItems = true
|
||||
IgnoreMissingManifestItems = false
|
||||
},
|
||||
BookCoverReaderOptions = new BookCoverReaderOptions
|
||||
{
|
||||
Epub2MetadataIgnoreMissingManifestItem = false
|
||||
}
|
||||
};
|
||||
|
||||
public static readonly EpubReaderOptions LenientBookReaderOptions = new()
|
||||
{
|
||||
PackageReaderOptions = new PackageReaderOptions
|
||||
{
|
||||
IgnoreMissingToc = true,
|
||||
SkipInvalidManifestItems = true,
|
||||
},
|
||||
Epub2NcxReaderOptions = new Epub2NcxReaderOptions
|
||||
{
|
||||
IgnoreMissingContentForNavigationPoints = false
|
||||
},
|
||||
SpineReaderOptions = new SpineReaderOptions
|
||||
{
|
||||
IgnoreMissingManifestItems = false
|
||||
},
|
||||
BookCoverReaderOptions = new BookCoverReaderOptions
|
||||
{
|
||||
@ -455,9 +476,12 @@ public class BookService : IBookService
|
||||
|
||||
private ComicInfo? GetEpubComicInfo(string filePath)
|
||||
{
|
||||
EpubBookRef? epubBook = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||
epubBook = OpenEpubWithFallback(filePath, epubBook);
|
||||
|
||||
var publicationDate =
|
||||
epubBook.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date;
|
||||
|
||||
@ -465,6 +489,7 @@ public class BookService : IBookService
|
||||
{
|
||||
publicationDate = epubBook.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date;
|
||||
}
|
||||
|
||||
var (year, month, day) = GetPublicationDate(publicationDate);
|
||||
|
||||
var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault();
|
||||
@ -476,7 +501,8 @@ public class BookService : IBookService
|
||||
Day = day,
|
||||
Year = year,
|
||||
Title = epubBook.Title,
|
||||
Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())),
|
||||
Genre = string.Join(",",
|
||||
epubBook.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim())),
|
||||
LanguageISO = ValidateLanguage(epubBook.Schema.Package.Metadata.Languages
|
||||
.Select(l => l.Language)
|
||||
.FirstOrDefault())
|
||||
@ -487,7 +513,8 @@ public class BookService : IBookService
|
||||
foreach (var identifier in epubBook.Schema.Package.Metadata.Identifiers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(identifier.Identifier)) continue;
|
||||
if (!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase))
|
||||
if (!string.IsNullOrEmpty(identifier.Scheme) &&
|
||||
identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
var isbn = identifier.Identifier.Replace("urn:isbn:", string.Empty).Replace("isbn:", string.Empty);
|
||||
if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn))
|
||||
@ -495,11 +522,13 @@ public class BookService : IBookService
|
||||
_logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
info.Isbn = isbn;
|
||||
}
|
||||
|
||||
if ((!string.IsNullOrEmpty(identifier.Scheme) && identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) ||
|
||||
identifier.Identifier.StartsWith("url:"))
|
||||
if ((!string.IsNullOrEmpty(identifier.Scheme) &&
|
||||
identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) ||
|
||||
identifier.Identifier.StartsWith("url:"))
|
||||
{
|
||||
var url = identifier.Identifier.Replace("url:", string.Empty);
|
||||
weblinks.Add(url.Trim());
|
||||
@ -529,6 +558,7 @@ public class BookService : IBookService
|
||||
{
|
||||
info.SeriesSort = metadataItem.Content;
|
||||
}
|
||||
|
||||
break;
|
||||
case "calibre:series_index":
|
||||
info.Volume = metadataItem.Content;
|
||||
@ -548,6 +578,7 @@ public class BookService : IBookService
|
||||
{
|
||||
info.SeriesSort = metadataItem.Content;
|
||||
}
|
||||
|
||||
break;
|
||||
case "collection-type":
|
||||
// These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series"
|
||||
@ -578,7 +609,8 @@ public class BookService : IBookService
|
||||
}
|
||||
|
||||
// If this is a single book and not a collection, set publication status to Completed
|
||||
if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume))
|
||||
if (string.IsNullOrEmpty(info.Volume) &&
|
||||
Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume))
|
||||
{
|
||||
info.Count = 1;
|
||||
}
|
||||
@ -590,7 +622,8 @@ public class BookService : IBookService
|
||||
var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga)
|
||||
.Equals(Parser.LooseLeafVolume);
|
||||
|
||||
if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series)))
|
||||
if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries &&
|
||||
(!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series)))
|
||||
{
|
||||
// This is likely a light novel for which we can set series from parsed title
|
||||
info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga);
|
||||
@ -601,14 +634,40 @@ public class BookService : IBookService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata");
|
||||
_logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata: {FilePath}", filePath);
|
||||
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
|
||||
"There was an exception parsing metadata", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
epubBook?.Dispose();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
|
||||
{
|
||||
try
|
||||
{
|
||||
epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"[GetComicInfo] There was an exception parsing metadata, falling back to a more lenient parsing method: {FilePath}",
|
||||
filePath);
|
||||
_mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
|
||||
"There was an exception parsing metadata", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
epubBook ??= EpubReader.OpenBook(filePath, LenientBookReaderOptions);
|
||||
}
|
||||
|
||||
return epubBook;
|
||||
}
|
||||
|
||||
public ComicInfo? GetComicInfo(string filePath)
|
||||
{
|
||||
if (!IsValidFile(filePath)) return null;
|
||||
@ -765,7 +824,7 @@ public class BookService : IBookService
|
||||
return docReader.GetPageCount();
|
||||
}
|
||||
|
||||
using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||
using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions);
|
||||
return epubBook.GetReadingOrder().Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -823,7 +882,7 @@ public class BookService : IBookService
|
||||
|
||||
try
|
||||
{
|
||||
using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
|
||||
using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions);
|
||||
|
||||
// <meta content="The Dark Tower" name="calibre:series"/>
|
||||
// <meta content="Wolves of the Calla" name="calibre:title_sort"/>
|
||||
@ -1027,7 +1086,7 @@ public class BookService : IBookService
|
||||
/// <returns></returns>
|
||||
public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookReaderOptions);
|
||||
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
|
||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||
|
||||
var navItems = await book.GetNavigationAsync();
|
||||
@ -1155,7 +1214,7 @@ public class BookService : IBookService
|
||||
/// <exception cref="KavitaException">All exceptions throw this</exception>
|
||||
public async Task<string> GetBookPage(int page, int chapterId, string cachedEpubPath, string baseUrl)
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, BookReaderOptions);
|
||||
using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
|
||||
var mappings = await CreateKeyToPageMappingAsync(book);
|
||||
var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl;
|
||||
|
||||
@ -1257,7 +1316,7 @@ public class BookService : IBookService
|
||||
return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size);
|
||||
}
|
||||
|
||||
using var epubBook = EpubReader.OpenBook(fileFilePath, BookReaderOptions);
|
||||
using var epubBook = EpubReader.OpenBook(fileFilePath, LenientBookReaderOptions);
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -17,6 +17,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
using MimeTypes;
|
||||
|
||||
namespace API.Services;
|
||||
#nullable enable
|
||||
@ -355,9 +356,21 @@ public class EmailService : IEmailService
|
||||
|
||||
if (userEmailOptions.Attachments != null)
|
||||
{
|
||||
foreach (var attachment in userEmailOptions.Attachments)
|
||||
foreach (var attachmentPath in userEmailOptions.Attachments)
|
||||
{
|
||||
await body.Attachments.AddAsync(attachment);
|
||||
var mimeType = MimeTypeMap.GetMimeType(attachmentPath) ?? "application/octet-stream";
|
||||
var mediaType = mimeType.Split('/')[0];
|
||||
var mediaSubtype = mimeType.Split('/')[1];
|
||||
|
||||
var attachment = new MimePart(mediaType, mediaSubtype)
|
||||
{
|
||||
Content = new MimeContent(File.OpenRead(attachmentPath)),
|
||||
ContentDisposition = new ContentDisposition(ContentDisposition.Attachment),
|
||||
ContentTransferEncoding = ContentEncoding.Base64,
|
||||
FileName = Path.GetFileName(attachmentPath)
|
||||
};
|
||||
|
||||
body.Attachments.Add(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -271,7 +271,7 @@ public class LocalizationService : ILocalizationService
|
||||
// This could use a lookup table or follow a naming convention
|
||||
try
|
||||
{
|
||||
var cultureInfo = new System.Globalization.CultureInfo(fileName);
|
||||
var cultureInfo = new System.Globalization.CultureInfo(fileName.Replace('_', '-'));
|
||||
return cultureInfo.NativeName;
|
||||
}
|
||||
catch
|
||||
|
@ -437,16 +437,24 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
// Trim quotes if the response is a JSON string
|
||||
errorMessage = errorMessage.Trim('"');
|
||||
|
||||
if (ex.StatusCode == 400 && errorMessage.Contains("Too many Requests"))
|
||||
if (ex.StatusCode == 400)
|
||||
{
|
||||
_logger.LogInformation("Hit rate limit, will retry in 3 seconds");
|
||||
await Task.Delay(3000);
|
||||
if (errorMessage.Contains("Too many Requests"))
|
||||
{
|
||||
_logger.LogInformation("Hit rate limit, will retry in 3 seconds");
|
||||
await Task.Delay(3000);
|
||||
|
||||
result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
|
||||
.WithKavitaPlusHeaders(license, token)
|
||||
.PostJsonAsync(data)
|
||||
.ReceiveJson<
|
||||
SeriesDetailPlusApiDto>();
|
||||
result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail")
|
||||
.WithKavitaPlusHeaders(license, token)
|
||||
.PostJsonAsync(data)
|
||||
.ReceiveJson<
|
||||
SeriesDetailPlusApiDto>();
|
||||
}
|
||||
else if (errorMessage.Contains("Unknown Series"))
|
||||
{
|
||||
series.IsBlacklisted = true;
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -956,6 +956,7 @@ public class ScrobblingService : IScrobblingService
|
||||
// Recalculate the highest volume/chapter
|
||||
foreach (var readEvt in readEvents)
|
||||
{
|
||||
// Note: this causes skewing in the scrobble history because it makes it look like there are duplicate events
|
||||
readEvt.VolumeNumber =
|
||||
(int) await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(readEvt.SeriesId,
|
||||
readEvt.AppUser.Id);
|
||||
@ -1027,7 +1028,7 @@ public class ScrobblingService : IScrobblingService
|
||||
_unitOfWork.ScrobbleRepository.Attach(new ScrobbleError()
|
||||
{
|
||||
Comment = "AniList token has expired and needs rotating. Scrobbling wont work until then",
|
||||
Details = $"User: {evt.AppUser.UserName}",
|
||||
Details = $"User: {evt.AppUser.UserName}, Expired: {TokenService.GetTokenExpiry(evt.AppUser.AniListAccessToken)}",
|
||||
LibraryId = evt.LibraryId,
|
||||
SeriesId = evt.SeriesId
|
||||
});
|
||||
@ -1124,33 +1125,22 @@ public class ScrobblingService : IScrobblingService
|
||||
private static bool CanProcessScrobbleEvent(ScrobbleEvent readEvent)
|
||||
{
|
||||
var userProviders = GetUserProviders(readEvent.AppUser);
|
||||
if (readEvent.Series.Library.Type == LibraryType.Manga && MangaProviders.Intersect(userProviders).Any())
|
||||
switch (readEvent.Series.Library.Type)
|
||||
{
|
||||
return true;
|
||||
case LibraryType.Manga when MangaProviders.Intersect(userProviders).Any():
|
||||
case LibraryType.Comic when
|
||||
ComicProviders.Intersect(userProviders).Any():
|
||||
case LibraryType.Book when
|
||||
BookProviders.Intersect(userProviders).Any():
|
||||
case LibraryType.LightNovel when
|
||||
LightNovelProviders.Intersect(userProviders).Any():
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (readEvent.Series.Library.Type == LibraryType.Comic &&
|
||||
ComicProviders.Intersect(userProviders).Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (readEvent.Series.Library.Type == LibraryType.Book &&
|
||||
BookProviders.Intersect(userProviders).Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (readEvent.Series.Library.Type == LibraryType.LightNovel &&
|
||||
LightNovelProviders.Intersect(userProviders).Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IList<ScrobbleProvider> GetUserProviders(AppUser appUser)
|
||||
private static List<ScrobbleProvider> GetUserProviders(AppUser appUser)
|
||||
{
|
||||
var providers = new List<ScrobbleProvider>();
|
||||
if (!string.IsNullOrEmpty(appUser.AniListAccessToken)) providers.Add(ScrobbleProvider.AniList);
|
||||
@ -1227,8 +1217,7 @@ public class ScrobblingService : IScrobblingService
|
||||
|
||||
public static string CreateUrl(string url, long? id)
|
||||
{
|
||||
if (id is null or 0) return string.Empty;
|
||||
return $"{url}{id}/";
|
||||
return id is null or 0 ? string.Empty : $"{url}{id}/";
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
@ -11,6 +12,7 @@ using API.Helpers;
|
||||
using API.SignalR;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
@ -33,6 +35,9 @@ public interface IStreamService
|
||||
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
|
||||
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
|
||||
Task DeleteExternalSource(int userId, int externalSourceId);
|
||||
Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId);
|
||||
Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId);
|
||||
Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter);
|
||||
}
|
||||
|
||||
public class StreamService : IStreamService
|
||||
@ -40,12 +45,14 @@ public class StreamService : IStreamService
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly ILogger<StreamService> _logger;
|
||||
|
||||
public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService)
|
||||
public StreamService(IUnitOfWork unitOfWork, IEventHub eventHub, ILocalizationService localizationService, ILogger<StreamService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_eventHub = eventHub;
|
||||
_localizationService = localizationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = true)
|
||||
@ -91,6 +98,7 @@ public class StreamService : IStreamService
|
||||
|
||||
var ret = new DashboardStreamDto()
|
||||
{
|
||||
Id = createdStream.Id,
|
||||
Name = createdStream.Name,
|
||||
IsProvided = createdStream.IsProvided,
|
||||
Visible = createdStream.Visible,
|
||||
@ -182,6 +190,7 @@ public class StreamService : IStreamService
|
||||
|
||||
var ret = new SideNavStreamDto()
|
||||
{
|
||||
Id = createdStream.Id,
|
||||
Name = createdStream.Name,
|
||||
IsProvided = createdStream.IsProvided,
|
||||
Visible = createdStream.Visible,
|
||||
@ -344,4 +353,72 @@ public class StreamService : IStreamService
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteSideNavSmartFilterStream(int userId, int sideNavStreamId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = await _unitOfWork.UserRepository.GetSideNavStream(sideNavStreamId);
|
||||
if (stream == null) throw new KavitaException("sidenav-stream-doesnt-exist");
|
||||
|
||||
if (stream.AppUserId != userId) throw new KavitaException("sidenav-stream-doesnt-exist");
|
||||
|
||||
|
||||
if (stream.StreamType != SideNavStreamType.SmartFilter)
|
||||
{
|
||||
throw new KavitaException("sidenav-stream-only-delete-smart-filter");
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Delete(stream);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception deleting SideNav Smart Filter Stream: {FilterId}", sideNavStreamId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteDashboardSmartFilterStream(int userId, int dashboardStreamId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stream = await _unitOfWork.UserRepository.GetDashboardStream(dashboardStreamId);
|
||||
if (stream == null) throw new KavitaException("dashboard-stream-doesnt-exist");
|
||||
|
||||
if (stream.AppUserId != userId) throw new KavitaException("dashboard-stream-doesnt-exist");
|
||||
|
||||
if (stream.StreamType != DashboardStreamType.SmartFilter)
|
||||
{
|
||||
throw new KavitaException("dashboard-stream-only-delete-smart-filter");
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Delete(stream);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception deleting Dashboard Smart Filter Stream: {FilterId}", dashboardStreamId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RenameSmartFilterStreams(AppUserSmartFilter smartFilter)
|
||||
{
|
||||
var sideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreamWithFilter(smartFilter.Id);
|
||||
var dashboardStreams = await _unitOfWork.UserRepository.GetDashboardStreamWithFilter(smartFilter.Id);
|
||||
|
||||
foreach (var sideNavStream in sideNavStreams)
|
||||
{
|
||||
sideNavStream.Name = smartFilter.Name;
|
||||
}
|
||||
|
||||
foreach (var dashboardStream in dashboardStreams)
|
||||
{
|
||||
dashboardStream.Name = smartFilter.Name;
|
||||
}
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
|
@ -329,7 +329,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) ||
|
||||
HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty]))
|
||||
{
|
||||
_logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||
_logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||
normalizedFolder);
|
||||
return;
|
||||
}
|
||||
@ -346,7 +346,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath);
|
||||
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty]))
|
||||
{
|
||||
_logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||
_logger.LogTrace("Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||
normalizedFolder);
|
||||
return;
|
||||
}
|
||||
|
@ -8,8 +8,10 @@ using API.DTOs.Filtering;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks;
|
||||
@ -35,6 +37,9 @@ public interface ICleanupService
|
||||
Task CleanupWantToRead();
|
||||
|
||||
Task ConsolidateProgress();
|
||||
|
||||
Task CleanupMediaErrors();
|
||||
|
||||
}
|
||||
/// <summary>
|
||||
/// Cleans up after operations on reoccurring basis
|
||||
@ -88,9 +93,11 @@ public class CleanupService : ICleanupService
|
||||
await CleanupBackups();
|
||||
|
||||
await SendProgress(0.35F, "Consolidating Progress Events");
|
||||
_logger.LogInformation("Consolidating Progress Events");
|
||||
await ConsolidateProgress();
|
||||
|
||||
await SendProgress(0.4F, "Consolidating Media Errors");
|
||||
await CleanupMediaErrors();
|
||||
|
||||
await SendProgress(0.50F, "Cleaning deleted cover images");
|
||||
_logger.LogInformation("Cleaning deleted cover images");
|
||||
await DeleteSeriesCoverImages();
|
||||
@ -241,6 +248,7 @@ public class CleanupService : ICleanupService
|
||||
/// </summary>
|
||||
public async Task ConsolidateProgress()
|
||||
{
|
||||
_logger.LogInformation("Consolidating Progress Events");
|
||||
// AppUserProgress
|
||||
var allProgress = await _unitOfWork.AppUserProgressRepository.GetAllProgress();
|
||||
|
||||
@ -291,6 +299,52 @@ public class CleanupService : ICleanupService
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans through Media Error and removes any entries that have been fixed and are within the DB (proper files where wordcount/pagecount > 0)
|
||||
/// </summary>
|
||||
public async Task CleanupMediaErrors()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<string> errorStrings = ["This archive cannot be read or not supported", "File format not supported"];
|
||||
var mediaErrors = await _unitOfWork.MediaErrorRepository.GetAllErrorsAsync(errorStrings);
|
||||
_logger.LogInformation("Beginning consolidation of {Count} Media Errors", mediaErrors.Count);
|
||||
|
||||
var pathToErrorMap = mediaErrors
|
||||
.GroupBy(me => Parser.NormalizePath(me.FilePath))
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.ToList() // The same file can be duplicated (rare issue when network drives die out midscan)
|
||||
);
|
||||
|
||||
var normalizedPaths = pathToErrorMap.Keys.ToList();
|
||||
|
||||
// Find all files that are valid
|
||||
var validFiles = await _unitOfWork.DataContext.MangaFile
|
||||
.Where(f => normalizedPaths.Contains(f.FilePath) && f.Pages > 0)
|
||||
.Select(f => f.FilePath)
|
||||
.ToListAsync();
|
||||
|
||||
var removalCount = 0;
|
||||
foreach (var validFilePath in validFiles)
|
||||
{
|
||||
if (!pathToErrorMap.TryGetValue(validFilePath, out var mediaError)) continue;
|
||||
|
||||
_unitOfWork.MediaErrorRepository.Remove(mediaError);
|
||||
removalCount++;
|
||||
}
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
_logger.LogInformation("Finished consolidation of {Count} Media Errors, Removed: {RemovalCount}",
|
||||
mediaErrors.Count, removalCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception consolidating media errors");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CleanupLogs()
|
||||
{
|
||||
_logger.LogInformation("Performing cleanup of logs directory");
|
||||
|
@ -179,7 +179,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
|
||||
var pageCounter = 1;
|
||||
try
|
||||
{
|
||||
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
|
||||
using var book = await EpubReader.OpenBookAsync(filePath, BookService.LenientBookReaderOptions);
|
||||
|
||||
var totalPages = book.Content.Html.Local;
|
||||
foreach (var bookPage in totalPages)
|
||||
|
@ -130,9 +130,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
||||
}
|
||||
|
||||
// Patch is SeriesSort from ComicInfo
|
||||
if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort))
|
||||
if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort))
|
||||
{
|
||||
info.SeriesSort = info.ComicInfo.TitleSort.Trim();
|
||||
info.SeriesSort = info.ComicInfo.SeriesSort.Trim();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -167,7 +167,7 @@ public static partial class Parser
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
|
||||
new Regex(
|
||||
@"제?(?<Volume>\d+(\.\d)?)(권|회|화|장)",
|
||||
@"제?(?<Volume>\d+(\.\d+)?)(권|회|화|장)",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Korean Season: 시즌n -> Season n,
|
||||
new Regex(
|
||||
|
@ -161,7 +161,7 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
|
||||
{
|
||||
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
|
||||
_logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -186,7 +186,7 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id))
|
||||
{
|
||||
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
|
||||
_logger.LogTrace("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1));
|
||||
|
@ -162,4 +162,10 @@ public class TokenService : ITokenService
|
||||
{
|
||||
return !JwtHelper.IsTokenValid(token);
|
||||
}
|
||||
|
||||
|
||||
public static DateTime GetTokenExpiry(string? token)
|
||||
{
|
||||
return JwtHelper.GetTokenExpiry(token);
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,9 @@ trim_trailing_whitespace = true
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
[en.json]
|
||||
indent_size = 4
|
||||
|
||||
[*.html]
|
||||
indent_size = 2
|
||||
|
||||
|
31
UI/Web/package-lock.json
generated
31
UI/Web/package-lock.json
generated
@ -39,9 +39,9 @@
|
||||
"luxon": "^3.5.0",
|
||||
"ng-circle-progress": "^1.7.1",
|
||||
"ng-lazyload-image": "^9.1.3",
|
||||
"ng-select2-component": "^17.2.1",
|
||||
"ng-select2-component": "^17.2.2",
|
||||
"ngx-color-picker": "^19.0.0",
|
||||
"ngx-extended-pdf-viewer": "^22.3.9",
|
||||
"ngx-extended-pdf-viewer": "^23.0.0-alpha.7",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-stars": "^1.6.5",
|
||||
"ngx-toastr": "^19.0.0",
|
||||
@ -542,7 +542,6 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.3.tgz",
|
||||
"integrity": "sha512-ePh/7A6eEDAyfVn8QgLcAvrxhXBAf6mTqB/3+HwQeXLaka1gtN6xvZ6cjLEegP4s6kcYGhdfdLwzCcy0kjsY5g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -570,7 +569,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@ -585,7 +583,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
@ -4907,8 +4904,7 @@
|
||||
"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==",
|
||||
"dev": true
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
@ -5355,7 +5351,6 @@
|
||||
"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"
|
||||
@ -5365,7 +5360,6 @@
|
||||
"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"
|
||||
@ -7420,9 +7414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ng-select2-component": {
|
||||
"version": "17.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.1.tgz",
|
||||
"integrity": "sha512-61CvdsFH/UbhEYwBr8j29eB3z8HMktLRRzNAbyl+PPTiZoGXdGGR9Bxatqw8go4vQBkwr5ju1JhsMQrECS0MvQ==",
|
||||
"version": "17.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ng-select2-component/-/ng-select2-component-17.2.2.tgz",
|
||||
"integrity": "sha512-dAeUSqmjU9Gexi47vMEz1bXGQkl3Be2O0wl6QqpYwFvM+QEfUyQiY0zWpYvB8shO1sIHoCQNKt9yTFcRzvzW0g==",
|
||||
"dependencies": {
|
||||
"ngx-infinite-scroll": ">=18.0.0 || >=19.0.0",
|
||||
"tslib": "^2.3.0"
|
||||
@ -7447,9 +7441,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-extended-pdf-viewer": {
|
||||
"version": "22.3.9",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-22.3.9.tgz",
|
||||
"integrity": "sha512-7DRR1P9UUx4VjG8GnFkXoXuvlgW1+Q2395u+24or7O8HDcIAve/QgokS7jizQmF55DNLsilYzunOBm0ezK5lHw==",
|
||||
"version": "23.0.0-alpha.7",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-23.0.0-alpha.7.tgz",
|
||||
"integrity": "sha512-S5jI9Z6p6wglLwvpf85MddxGKYUiJczb02nZcFWztDSZ7BlKXkjdtssW+chBOc/sg46p2kTDoa0M/R07yqRFcA==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -8184,8 +8178,7 @@
|
||||
"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==",
|
||||
"dev": true
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
},
|
||||
"node_modules/replace-in-file": {
|
||||
"version": "7.1.0",
|
||||
@ -8406,7 +8399,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.0",
|
||||
@ -8471,7 +8464,6 @@
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@ -9096,7 +9088,6 @@
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
@ -47,9 +47,9 @@
|
||||
"luxon": "^3.5.0",
|
||||
"ng-circle-progress": "^1.7.1",
|
||||
"ng-lazyload-image": "^9.1.3",
|
||||
"ng-select2-component": "^17.2.1",
|
||||
"ng-select2-component": "^17.2.2",
|
||||
"ngx-color-picker": "^19.0.0",
|
||||
"ngx-extended-pdf-viewer": "^22.3.9",
|
||||
"ngx-extended-pdf-viewer": "^23.0.0-alpha.7",
|
||||
"ngx-file-drop": "^16.0.0",
|
||||
"ngx-stars": "^1.6.5",
|
||||
"ngx-toastr": "^19.0.0",
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { MangaFormat } from "../manga-format";
|
||||
import { SeriesFilterV2 } from "./v2/series-filter-v2";
|
||||
import {FilterField} from "./v2/filter-field";
|
||||
import {MangaFormat} from "../manga-format";
|
||||
import {SeriesFilterV2} from "./v2/series-filter-v2";
|
||||
|
||||
export interface FilterItem<T> {
|
||||
title: string;
|
||||
@ -34,22 +33,22 @@ export const allSortFields = Object.keys(SortField)
|
||||
|
||||
export const mangaFormatFilters = [
|
||||
{
|
||||
title: 'Images',
|
||||
title: 'images',
|
||||
value: MangaFormat.IMAGE,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'EPUB',
|
||||
title: 'epub',
|
||||
value: MangaFormat.EPUB,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'PDF',
|
||||
title: 'pdf',
|
||||
value: MangaFormat.PDF,
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
title: 'ARCHIVE',
|
||||
title: 'archive',
|
||||
value: MangaFormat.ARCHIVE,
|
||||
selected: false
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ export enum WikiLink {
|
||||
SeriesRelationships = 'https://wiki.kavitareader.com/guides/features/relationships',
|
||||
Bookmarks = 'https://wiki.kavitareader.com/guides/features/bookmarks',
|
||||
DataCollection = 'https://wiki.kavitareader.com/troubleshooting/faq#q-does-kavita-collect-any-data-on-me',
|
||||
MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/media#media-issues',
|
||||
MediaIssues = 'https://wiki.kavitareader.com/guides/admin-settings/mediaissues/',
|
||||
KavitaPlusDiscordId = 'https://wiki.kavitareader.com/guides/admin-settings/kavita+#discord-id',
|
||||
KavitaPlus = 'https://wiki.kavitareader.com/kavita+/features/',
|
||||
KavitaPlusFAQ = 'https://wiki.kavitareader.com/kavita+/faq',
|
||||
|
@ -1,22 +1,22 @@
|
||||
import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { AgeRating } from '../_models/metadata/age-rating';
|
||||
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
|
||||
import {AgeRating} from '../_models/metadata/age-rating';
|
||||
import {AgeRatingDto} from '../_models/metadata/age-rating-dto';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'ageRating',
|
||||
standalone: true
|
||||
standalone: true,
|
||||
pure: true
|
||||
})
|
||||
export class AgeRatingPipe implements PipeTransform {
|
||||
|
||||
translocoService = inject(TranslocoService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: AgeRating | AgeRatingDto | undefined): Observable<string> {
|
||||
if (value === undefined || value === null) return of(this.translocoService.translate('age-rating-pipe.unknown') as string);
|
||||
transform(value: AgeRating | AgeRatingDto | undefined): string {
|
||||
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
||||
if (value.hasOwnProperty('title')) {
|
||||
return of((value as AgeRatingDto).title);
|
||||
return (value as AgeRatingDto).title;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
@ -54,7 +54,7 @@ export class AgeRatingPipe implements PipeTransform {
|
||||
return this.translocoService.translate('age-rating-pipe.r18-plus');
|
||||
}
|
||||
|
||||
return of(this.translocoService.translate('age-rating-pipe.unknown') as string);
|
||||
return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
}
|
||||
|
||||
}
|
||||
|
17
UI/Web/src/app/_pipes/log-level.pipe.ts
Normal file
17
UI/Web/src/app/_pipes/log-level.pipe.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
/**
|
||||
* Transforms the log level string into a localized string
|
||||
*/
|
||||
@Pipe({
|
||||
name: 'logLevel',
|
||||
standalone: true,
|
||||
pure: true
|
||||
})
|
||||
export class LogLevelPipe implements PipeTransform {
|
||||
transform(value: string): string {
|
||||
return translate('log-level-pipe.' + value.toLowerCase());
|
||||
}
|
||||
|
||||
}
|
15
UI/Web/src/app/_pipes/role-localized.pipe.ts
Normal file
15
UI/Web/src/app/_pipes/role-localized.pipe.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {Role} from "../_services/account.service";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'roleLocalized'
|
||||
})
|
||||
export class RoleLocalizedPipe implements PipeTransform {
|
||||
|
||||
transform(value: Role | string): string {
|
||||
const key = (value + '').toLowerCase().replace(' ', '-');
|
||||
return translate(`role-localized-pipe.${key}`);
|
||||
}
|
||||
|
||||
}
|
@ -1,14 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { map, Observable, shareReplay } from 'rxjs';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {map, Observable, shareReplay} from 'rxjs';
|
||||
import {Chapter} from '../_models/chapter';
|
||||
import {UserCollection} from '../_models/collection-tag';
|
||||
import { Device } from '../_models/device/device';
|
||||
import { Library } from '../_models/library/library';
|
||||
import { ReadingList } from '../_models/reading-list';
|
||||
import { Series } from '../_models/series';
|
||||
import { Volume } from '../_models/volume';
|
||||
import { AccountService } from './account.service';
|
||||
import { DeviceService } from './device.service';
|
||||
import {Device} from '../_models/device/device';
|
||||
import {Library} from '../_models/library/library';
|
||||
import {ReadingList} from '../_models/reading-list';
|
||||
import {Series} from '../_models/series';
|
||||
import {Volume} from '../_models/volume';
|
||||
import {AccountService} from './account.service';
|
||||
import {DeviceService} from './device.service';
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
@ -170,6 +170,8 @@ export class ActionFactoryService {
|
||||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
|
||||
sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||
|
||||
isAdmin = false;
|
||||
|
||||
|
||||
@ -226,6 +228,10 @@ export class ActionFactoryService {
|
||||
return this.applyCallbackToList(this.personActions, callback);
|
||||
}
|
||||
|
||||
getSideNavHomeActions(callback: ActionCallback<void>) {
|
||||
return this.applyCallbackToList(this.sideNavHomeActions, callback);
|
||||
}
|
||||
|
||||
dummyCallback(action: ActionItem<any>, data: any) {}
|
||||
|
||||
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
|
||||
@ -873,6 +879,19 @@ export class ActionFactoryService {
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.sideNavHomeActions = [
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'reorder',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
}
|
||||
|
||||
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
|
||||
|
||||
@ -26,4 +26,8 @@ export class DashboardService {
|
||||
createDashboardStream(smartFilterId: number) {
|
||||
return this.httpClient.post<DashboardStream>(this.baseUrl + 'stream/add-dashboard-stream?smartFilterId=' + smartFilterId, {});
|
||||
}
|
||||
|
||||
deleteSmartFilterStream(streamId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-dashboard-stream?dashboardStreamId=' + streamId, {});
|
||||
}
|
||||
}
|
||||
|
@ -23,4 +23,8 @@ export class FilterService {
|
||||
return this.httpClient.delete(this.baseUrl + 'filter?filterId=' + filterId);
|
||||
}
|
||||
|
||||
renameSmartFilter(filter: SmartFilter) {
|
||||
return this.httpClient.post(this.baseUrl + `filter/rename?filterId=${filter.id}&name=${filter.name.trim()}`, {});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,11 +1,9 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {catchError, map, of, ReplaySubject, tap, throwError} from "rxjs";
|
||||
import {catchError, map, ReplaySubject, tap, throwError} from "rxjs";
|
||||
import {environment} from "../../environments/environment";
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {LicenseInfo} from "../_models/kavitaplus/license-info";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ConfirmService} from "../shared/confirm.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -58,7 +56,6 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
hasValidLicense(forceCheck: boolean = false) {
|
||||
console.log('hasValidLicense being called: ', forceCheck);
|
||||
return this.httpClient.get<string>(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse)
|
||||
.pipe(
|
||||
map(res => res === "true"),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
|
||||
import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs';
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {filter, ReplaySubject, take} from 'rxjs';
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
@ -93,6 +93,10 @@ export class NavService {
|
||||
return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility});
|
||||
}
|
||||
|
||||
deleteSideNavSmartFilter(streamId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + 'stream/smart-filter-side-nav-stream?sideNavStreamId=' + streamId, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the top nav bar. This should be visible on all pages except the reader.
|
||||
*/
|
||||
|
@ -7,7 +7,9 @@
|
||||
<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;">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeId" class="nav-pills"
|
||||
[destroyOnHide]="false"
|
||||
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
|
||||
<!-- General Tab -->
|
||||
@if (user && accountService.hasAdminRole(user))
|
||||
@ -102,20 +104,22 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if (languageSettings) {
|
||||
<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>
|
||||
|
||||
@ -166,39 +170,43 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if (genreSettings) {
|
||||
<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>
|
||||
@if (tagsSettings) {
|
||||
<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.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>
|
||||
@ -207,39 +215,44 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if (getPersonsSettings(PersonRole.Imprint); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@if (getPersonsSettings(PersonRole.Publisher); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@ -248,39 +261,43 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if (getPersonsSettings(PersonRole.Team); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@if (getPersonsSettings(PersonRole.Location); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@ -289,20 +306,22 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if (getPersonsSettings(PersonRole.Character); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@ -322,39 +341,43 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if (getPersonsSettings(PersonRole.Writer); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@if (getPersonsSettings(PersonRole.CoverArtist); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@ -363,39 +386,43 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if(getPersonsSettings(PersonRole.Penciller); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@if(getPersonsSettings(PersonRole.Colorist); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@ -404,39 +431,43 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if(getPersonsSettings(PersonRole.Inker); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@if(getPersonsSettings(PersonRole.Letterer); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
@ -445,20 +476,22 @@
|
||||
<div class="row">
|
||||
<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>
|
||||
@if(getPersonsSettings(PersonRole.Translator); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
|
@ -1,20 +1,8 @@
|
||||
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,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
TitleCasePipe
|
||||
} from "@angular/common";
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet
|
||||
} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {AsyncPipe, NgClass, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
||||
import {NgbActiveModal, 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";
|
||||
@ -41,10 +29,8 @@ import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-
|
||||
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
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";
|
||||
@ -53,7 +39,6 @@ import {ReadTimePipe} from "../../_pipes/read-time.pipe";
|
||||
import {ChapterService} from "../../_services/chapter.service";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {User} from "../../_models/user";
|
||||
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
|
||||
|
||||
enum TabID {
|
||||
General = 'general-tab',
|
||||
@ -258,16 +243,15 @@ export class EditChapterModalComponent implements OnInit {
|
||||
const model = this.editForm.value;
|
||||
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
|
||||
|
||||
// Patch in data from the model that is not typeahead (as those are updated during setting)
|
||||
if (model.releaseDate === '') {
|
||||
this.chapter.releaseDate = '0001-01-01T00:00:00';
|
||||
} else {
|
||||
this.chapter.releaseDate = model.releaseDate + 'T00:00:00';
|
||||
}
|
||||
|
||||
this.chapter.ageRating = parseInt(model.ageRating + '', 10) as AgeRating;
|
||||
this.chapter.genres = model.genres;
|
||||
this.chapter.tags = model.tags;
|
||||
this.chapter.sortOrder = model.sortOrder;
|
||||
this.chapter.language = model.language;
|
||||
this.chapter.titleName = model.titleName;
|
||||
this.chapter.summary = model.summary;
|
||||
this.chapter.isbn = model.isbn;
|
||||
@ -359,6 +343,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.tagsSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
|
||||
|
||||
if (this.chapter.tags) {
|
||||
this.tagsSettings.savedData = this.chapter.tags;
|
||||
@ -390,6 +375,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
this.genreSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title };
|
||||
});
|
||||
this.genreSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
|
||||
|
||||
if (this.chapter.genres) {
|
||||
this.genreSettings.savedData = this.chapter.genres;
|
||||
@ -416,6 +402,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
|
||||
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.chapter.language);
|
||||
if (l !== undefined) {
|
||||
@ -427,6 +414,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
|
||||
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 => {
|
||||
@ -437,10 +425,11 @@ export class EditChapterModalComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
return true;
|
||||
}));
|
||||
} else {
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
}
|
||||
|
||||
this.peopleSettings[role] = personSettings;
|
||||
return of(true);
|
||||
|
||||
}
|
||||
|
||||
setupPersonTypeahead() {
|
||||
@ -460,7 +449,7 @@ export class EditChapterModalComponent implements OnInit {
|
||||
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 => {
|
||||
]).pipe(map(_ => {
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
@ -497,6 +486,8 @@ export class EditChapterModalComponent implements OnInit {
|
||||
return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
|
||||
});
|
||||
|
||||
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');
|
||||
|
||||
return personSettings;
|
||||
}
|
||||
|
||||
|
@ -35,12 +35,12 @@
|
||||
[count]="pageInfo.totalElements"
|
||||
[offset]="pageInfo.pageNumber"
|
||||
[limit]="pageInfo.size"
|
||||
[sorts]="[{prop: 'lastModifiedUtc', dir: 'desc'}]"
|
||||
[sorts]="[{prop: 'createdUtc', dir: 'desc'}]"
|
||||
>
|
||||
|
||||
<ngx-datatable-column prop="lastModifiedUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
|
||||
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
|
||||
<ng-template let-column="column" ngx-datatable-header-template>
|
||||
{{t('last-modified-header')}}
|
||||
{{t('created-header')}}
|
||||
</ng-template>
|
||||
<ng-template let-value="value" ngx-datatable-cell-template>
|
||||
{{value | utcToLocalTime | defaultValue }}
|
||||
|
@ -14,16 +14,19 @@
|
||||
<label for="select-all" class="form-check-label">{{selectAll ? t('deselect-all') : t('select-all')}}</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="allLibraries.length === 0">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
@for (library of allLibraries; track library.name; let i = $index) {
|
||||
<li class="list-group-item">
|
||||
<div class="form-check">
|
||||
<input id="library-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
|
||||
<label for="library-{{i}}" class="form-check-label">{{library.name}}</label>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="list-group-item">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,7 +3,6 @@ import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {Library} from 'src/app/_models/library/library';
|
||||
import {Member} from 'src/app/_models/auth/member';
|
||||
import {LibraryService} from 'src/app/_services/library.service';
|
||||
import {NgFor, NgIf} from '@angular/common';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SelectionModel} from "../../../typeahead/_models/selection-model";
|
||||
@ -13,7 +12,7 @@ import {SelectionModel} from "../../../typeahead/_models/selection-model";
|
||||
templateUrl: './library-access-modal.component.html',
|
||||
styleUrls: ['./library-access-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, FormsModule, NgFor, NgIf, TranslocoDirective],
|
||||
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class LibraryAccessModalComponent implements OnInit {
|
||||
@ -23,6 +22,7 @@ export class LibraryAccessModalComponent implements OnInit {
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
|
||||
@Input() member: Member | undefined;
|
||||
|
||||
allLibraries: Library[] = [];
|
||||
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
|
||||
selections!: SelectionModel<Library>;
|
||||
|
@ -7,9 +7,12 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" *ngIf="errorMessage !== ''">
|
||||
<strong>{{t('error-label')}}</strong> {{errorMessage}}
|
||||
</div>
|
||||
@if (errorMessage !== '') {
|
||||
<div class="alert alert-info">
|
||||
<strong>{{t('error-label')}}</strong> {{errorMessage}}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">{{t('new-password-label')}}</label>
|
||||
<input id="password" class="form-control" minlength="4" formControlName="password" type="password">
|
||||
|
@ -1,24 +1,23 @@
|
||||
import {Component, inject, Input} from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Member } from 'src/app/_models/auth/member';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {Member} from 'src/app/_models/auth/member';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {SentenceCasePipe} from '../../../_pipes/sentence-case.pipe';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-reset-password-modal',
|
||||
templateUrl: './reset-password-modal.component.html',
|
||||
styleUrls: ['./reset-password-modal.component.scss'],
|
||||
imports: [ReactiveFormsModule, NgIf, SentenceCasePipe, TranslocoDirective]
|
||||
selector: 'app-reset-password-modal',
|
||||
templateUrl: './reset-password-modal.component.html',
|
||||
styleUrls: ['./reset-password-modal.component.scss'],
|
||||
imports: [ReactiveFormsModule, SentenceCasePipe, TranslocoDirective]
|
||||
})
|
||||
export class ResetPasswordModalComponent {
|
||||
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
public readonly modal = inject(NgbActiveModal);
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input({required: true}) member!: Member;
|
||||
|
||||
|
@ -14,12 +14,17 @@
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
{{t('required-field')}}
|
||||
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required
|
||||
[class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
@if (inviteForm.dirty || !inviteForm.untouched) {
|
||||
<div id="inviteForm-validations" class="invalid-feedback">
|
||||
@if (email?.errors?.required) {
|
||||
<div>
|
||||
{{t('required-field')}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -41,8 +46,7 @@
|
||||
</form>
|
||||
}
|
||||
|
||||
|
||||
<ng-container *ngIf="emailLink !== ''">
|
||||
@if (emailLink !== '') {
|
||||
<h4>{{t('setup-user-title')}}</h4>
|
||||
<p>{{t('setup-user-description')}}</p>
|
||||
@if (inviteError) {
|
||||
@ -52,17 +56,23 @@
|
||||
}
|
||||
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">{{t('setup-user-account')}}</a>
|
||||
<app-api-key [title]="t('invite-url-label')" [tooltipText]="t('setup-user-account-tooltip')" [hideData]="false" [showRefresh]="false" [transform]="makeLink"></app-api-key>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-{{invited ? 'primary' : 'secondary'}}" (click)="close()">
|
||||
{{invited ? t('cancel') : t('close')}}
|
||||
</button>
|
||||
<button *ngIf="!invited" type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
|
||||
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>{{isSending ? t('inviting') : t('invite')}}</span>
|
||||
</button>
|
||||
|
||||
@if (!invited) {
|
||||
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || emailLink !== ''">
|
||||
@if (isSending) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
<span>{{isSending ? t('inviting') : t('invite')}}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -1,17 +1,16 @@
|
||||
import {ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { AgeRestriction } from 'src/app/_models/metadata/age-restriction';
|
||||
import { InviteUserResponse } from 'src/app/_models/auth/invite-user-response';
|
||||
import { Library } from 'src/app/_models/library/library';
|
||||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ApiKeyComponent } from '../../user-settings/api-key/api-key.component';
|
||||
import { RestrictionSelectorComponent } from '../../user-settings/restriction-selector/restriction-selector.component';
|
||||
import { LibrarySelectorComponent } from '../library-selector/library-selector.component';
|
||||
import { RoleSelectorComponent } from '../role-selector/role-selector.component';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
|
||||
import {InviteUserResponse} from 'src/app/_models/auth/invite-user-response';
|
||||
import {Library} from 'src/app/_models/library/library';
|
||||
import {AgeRating} from 'src/app/_models/metadata/age-rating';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ApiKeyComponent} from '../../user-settings/api-key/api-key.component';
|
||||
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
|
||||
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
|
||||
import {RoleSelectorComponent} from '../role-selector/role-selector.component';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
|
||||
@ -19,10 +18,16 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
selector: 'app-invite-user',
|
||||
templateUrl: './invite-user.component.html',
|
||||
styleUrls: ['./invite-user.component.scss'],
|
||||
imports: [NgIf, ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, ApiKeyComponent, TranslocoDirective, SafeHtmlPipe]
|
||||
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent,
|
||||
ApiKeyComponent, TranslocoDirective, SafeHtmlPipe]
|
||||
})
|
||||
export class InviteUserComponent implements OnInit {
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
|
||||
/**
|
||||
* Maintains if the backend is sending an email
|
||||
*/
|
||||
@ -35,15 +40,13 @@ export class InviteUserComponent implements OnInit {
|
||||
invited: boolean = false;
|
||||
inviteError: boolean = false;
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
|
||||
makeLink: (val: string) => string = (_: string) => {return this.emailLink};
|
||||
|
||||
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
|
||||
get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
|
||||
|
||||
public get email() { return this.inviteForm.get('email'); }
|
||||
get email() { return this.inviteForm.get('email'); }
|
||||
|
||||
constructor(public modal: NgbActiveModal, private accountService: AccountService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
|
||||
@ -88,14 +91,17 @@ export class InviteUserComponent implements OnInit {
|
||||
|
||||
updateRoleSelection(roles: Array<string>) {
|
||||
this.selectedRoles = roles;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateLibrarySelection(libraries: Array<Library>) {
|
||||
this.selectedLibraries = libraries.map(l => l.id);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateRestrictionSelection(restriction: AgeRestriction) {
|
||||
this.selectedRestriction = restriction;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -2,22 +2,22 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
OnInit,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
inject,
|
||||
DestroyRef
|
||||
ViewChildren
|
||||
} from '@angular/core';
|
||||
import { BehaviorSubject, Observable, filter, shareReplay } from 'rxjs';
|
||||
import { SortEvent, SortableHeader, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||
import { KavitaMediaError } from '../_models/media-error';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import {BehaviorSubject, filter, Observable, shareReplay} from 'rxjs';
|
||||
import {compare, SortableHeader, SortEvent} from 'src/app/_single-module/table/_directives/sortable-header.directive';
|
||||
import {KavitaMediaError} from '../_models/media-error';
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { FilterPipe } from '../../_pipes/filter.pipe';
|
||||
import {FilterPipe} from '../../_pipes/filter.pipe';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {WikiLink} from "../../_models/wiki";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
@ -28,8 +28,8 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
|
||||
selector: 'app-manage-media-issues',
|
||||
templateUrl: './manage-media-issues.component.html',
|
||||
styleUrls: ['./manage-media-issues.component.scss'],
|
||||
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule]
|
||||
})
|
||||
export class ManageMediaIssuesComponent implements OnInit {
|
||||
|
||||
|
@ -170,14 +170,14 @@
|
||||
@if (settingsForm.get('loggingLevel'); as formControl) {
|
||||
<app-setting-item [title]="t('logging-level-label')" [subtitle]="t('logging-level-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value | titlecase}}
|
||||
{{formControl.value | logLevel}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
|
||||
<select id="logging-level" aria-describedby="logging-level-help" class="form-select" formControlName="loggingLevel"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
@for(level of logLevels; track level) {
|
||||
<option [value]="level">{{level | titlecase}}</option>
|
||||
<option [value]="level">{{level | logLevel}}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
|
@ -5,7 +5,6 @@ import {take} from 'rxjs/operators';
|
||||
import {ServerService} from 'src/app/_services/server.service';
|
||||
import {SettingsService} from '../settings.service';
|
||||
import {ServerSettings} from '../_models/server-settings';
|
||||
import {TitleCasePipe} from '@angular/common';
|
||||
import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {WikiLink} from "../../_models/wiki";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
@ -15,6 +14,7 @@ import {debounceTime, distinctUntilChanged, filter, switchMap, tap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {EnterBlurDirective} from "../../_directives/enter-blur.directive";
|
||||
import {LogLevelPipe} from "../../_pipes/log-level.pipe";
|
||||
|
||||
const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i;
|
||||
|
||||
@ -23,7 +23,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:
|
||||
templateUrl: './manage-settings.component.html',
|
||||
styleUrls: ['./manage-settings.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, EnterBlurDirective]
|
||||
imports: [ReactiveFormsModule, TranslocoModule, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe, EnterBlurDirective, LogLevelPipe]
|
||||
})
|
||||
export class ManageSettingsComponent implements OnInit {
|
||||
|
||||
|
@ -36,13 +36,17 @@
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (!hasAdminRole(member) && member.libraries.length > 0) {
|
||||
@if (member.libraries.length > 5) {
|
||||
{{t('too-many-libraries')}}
|
||||
}
|
||||
@else {
|
||||
@for(lib of member.libraries; track lib.name) {
|
||||
<app-tag-badge class="col-auto">{{lib.name}}</app-tag-badge>
|
||||
@if (member.libraries.length > 0) {
|
||||
@if (hasAdminRole(member)) {
|
||||
{{t('all-libraries')}}
|
||||
} @else {
|
||||
@if (member.libraries.length > 5) {
|
||||
{{t('too-many-libraries')}}
|
||||
}
|
||||
@else {
|
||||
@for(lib of member.libraries; track lib.name) {
|
||||
<app-tag-badge class="col-auto">{{lib.name}}</app-tag-badge>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
@ -56,10 +60,10 @@
|
||||
<span>{{null | defaultValue}}</span>
|
||||
} @else {
|
||||
@if (hasAdminRole(member)) {
|
||||
<app-tag-badge class="col-auto">{{t('admin')}}</app-tag-badge>
|
||||
<app-tag-badge class="col-auto">{{Role.Admin | roleLocalized}}</app-tag-badge>
|
||||
} @else {
|
||||
@for (role of roles; track role) {
|
||||
<app-tag-badge class="col-auto">{{role}}</app-tag-badge>
|
||||
<app-tag-badge class="col-auto">{{role | roleLocalized}}</app-tag-badge>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {take} from 'rxjs/operators';
|
||||
import {MemberService} from 'src/app/_services/member.service';
|
||||
import {Member} from 'src/app/_models/auth/member';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {AccountService, Role} from 'src/app/_services/account.service';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {ResetPasswordModalComponent} from '../_modals/reset-password-modal/reset-password-modal.component';
|
||||
import {ConfirmService} from 'src/app/shared/confirm.service';
|
||||
@ -17,22 +17,26 @@ import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {makeBindingParser} from "@angular/compiler";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {TimeAgoPipe} from "../../_pipes/time-ago.pipe";
|
||||
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
|
||||
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
templateUrl: './manage-users.component.html',
|
||||
styleUrls: ['./manage-users.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass, DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe]
|
||||
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
|
||||
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
|
||||
RoleLocalizedPipe]
|
||||
})
|
||||
export class ManageUsersComponent implements OnInit {
|
||||
|
||||
protected readonly Role = Role;
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly memberService = inject(MemberService);
|
||||
@ -153,6 +157,4 @@ export class ManageUsersComponent implements OnInit {
|
||||
getRoles(member: Member) {
|
||||
return member.roles.filter(item => item != 'Pleb');
|
||||
}
|
||||
|
||||
protected readonly makeBindingParser = makeBindingParser;
|
||||
}
|
||||
|
@ -20,7 +20,7 @@
|
||||
<div class="form-check">
|
||||
<input id="role-{{i}}" type="checkbox" class="form-check-input"
|
||||
[(ngModel)]="role.selected" [disabled]="role.disabled" name="role" (ngModelChange)="handleModelUpdate()">
|
||||
<label for="role-{{i}}" class="form-check-label">{{role.data}}</label>
|
||||
<label for="role-{{i}}" class="form-check-label">{{role.data | roleLocalized}}</label>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
@ -14,13 +14,14 @@ import {AccountService} from 'src/app/_services/account.service';
|
||||
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
|
||||
import {TranslocoDirective,} from "@jsverse/transloco";
|
||||
import {SelectionModel} from "../../typeahead/_models/selection-model";
|
||||
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-role-selector',
|
||||
templateUrl: './role-selector.component.html',
|
||||
styleUrls: ['./role-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective]
|
||||
imports: [ReactiveFormsModule, FormsModule, TranslocoDirective, RoleLocalizedPipe]
|
||||
})
|
||||
export class RoleSelectorComponent implements OnInit {
|
||||
|
||||
|
@ -4,24 +4,29 @@
|
||||
<h4 title>
|
||||
{{title}}
|
||||
</h4>
|
||||
<h5 subtitle *ngIf="pagination">{{t('series-count', {num: pagination.totalItems | number})}}</h5>
|
||||
@if (pagination) {
|
||||
<h5 subtitle>{{t('series-count', {num: pagination.totalItems | number})}}</h5>
|
||||
}
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout *ngIf="filter"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<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>
|
||||
</app-card-detail-layout>
|
||||
@if (filter) {
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpbarKeys"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<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>
|
||||
</app-card-detail-layout>
|
||||
}
|
||||
|
||||
|
||||
</ng-container>
|
||||
</div>
|
||||
|
@ -1,48 +1,63 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { take, debounceTime } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
|
||||
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
|
||||
import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { FilterEvent } from 'src/app/_models/metadata/series-filter';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { JumpbarService } from 'src/app/_services/jumpbar.service';
|
||||
import { MessageHubService, Message, EVENTS } from 'src/app/_services/message-hub.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {debounceTime, take} from 'rxjs/operators';
|
||||
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
|
||||
import {FilterSettings} from 'src/app/metadata-filter/filter-settings';
|
||||
import {FilterUtilitiesService} from 'src/app/shared/_services/filter-utilities.service';
|
||||
import {UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
import {Pagination} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {FilterEvent} from 'src/app/_models/metadata/series-filter';
|
||||
import {Action, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {ActionService} from 'src/app/_services/action.service';
|
||||
import {JumpbarService} from 'src/app/_services/jumpbar.service';
|
||||
import {EVENTS, Message, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SeriesCardComponent } from '../../../cards/series-card/series-card.component';
|
||||
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { BulkOperationsComponent } from '../../../cards/bulk-operations/bulk-operations.component';
|
||||
import { NgIf, DecimalPipe } from '@angular/common';
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {SeriesCardComponent} from '../../../cards/series-card/series-card.component';
|
||||
import {CardDetailLayoutComponent} from '../../../cards/card-detail-layout/card-detail-layout.component';
|
||||
import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component';
|
||||
import {DecimalPipe} from '@angular/common';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SeriesFilterV2} from "../../../_models/metadata/v2/series-filter-v2";
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-all-series',
|
||||
templateUrl: './all-series.component.html',
|
||||
styleUrls: ['./all-series.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SideNavCompanionBarComponent, NgIf, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, DecimalPipe, TranslocoDirective]
|
||||
selector: 'app-all-series',
|
||||
templateUrl: './all-series.component.html',
|
||||
styleUrls: ['./all-series.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SideNavCompanionBarComponent, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent,
|
||||
DecimalPipe, TranslocoDirective],
|
||||
})
|
||||
export class AllSeriesComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly hubService = inject(MessageHubService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly jumpbarService = inject(JumpbarService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
|
||||
title: string = translate('side-nav.all-series');
|
||||
series: Series[] = [];
|
||||
loadingSeries = false;
|
||||
@ -53,7 +68,7 @@ export class AllSeriesComponent implements OnInit {
|
||||
filterActiveCheck!: SeriesFilterV2;
|
||||
filterActive: boolean = false;
|
||||
jumpbarKeys: Array<JumpKey> = [];
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
bulkActionCallback = (action: ActionItem<any>, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
@ -103,13 +118,10 @@ export class AllSeriesComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private router: Router, private seriesService: SeriesService,
|
||||
private titleService: Title, private actionService: ActionService,
|
||||
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService, private route: ActivatedRoute,
|
||||
private filterUtilityService: FilterUtilitiesService, private jumpbarService: JumpbarService,
|
||||
private readonly cdRef: ChangeDetectorRef) {
|
||||
|
||||
|
||||
|
||||
constructor() {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
|
||||
@ -140,7 +152,7 @@ export class AllSeriesComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
|
||||
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((_) => {
|
||||
this.loadPage();
|
||||
});
|
||||
}
|
||||
@ -163,5 +175,5 @@ export class AllSeriesComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
trackByIdentity = (_: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ import {
|
||||
import {DOCUMENT, NgClass, NgIf, NgStyle, NgTemplateOutlet} from '@angular/common';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {forkJoin, fromEvent, of} from 'rxjs';
|
||||
import {forkJoin, fromEvent, merge, of} from 'rxjs';
|
||||
import {catchError, debounceTime, distinctUntilChanged, take, tap} from 'rxjs/operators';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
@ -515,7 +515,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.handleScrollEvent();
|
||||
});
|
||||
|
||||
fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mousemove')
|
||||
const mouseMove$ = fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mousemove');
|
||||
const touchMove$ = fromEvent<TouchEvent>(this.bookContainerElemRef.nativeElement, 'touchmove');
|
||||
|
||||
merge(mouseMove$, touchMove$)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
distinctUntilChanged(),
|
||||
@ -527,7 +530,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mouseup')
|
||||
const mouseUp$ = fromEvent<MouseEvent>(this.bookContainerElemRef.nativeElement, 'mouseup');
|
||||
const touchEnd$ = fromEvent<TouchEvent>(this.bookContainerElemRef.nativeElement, 'touchend');
|
||||
|
||||
merge(mouseUp$, touchEnd$)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
distinctUntilChanged(),
|
||||
|
@ -8,7 +8,8 @@
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||
<form [formGroup]="editSeriesForm">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" [destroyOnHide]="false"
|
||||
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.General]">
|
||||
<a ngbNavLink>{{t(tabs[TabID.General])}}</a>
|
||||
@ -94,20 +95,22 @@
|
||||
<div class="row">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</app-setting-item>
|
||||
@if (languageSettings) {
|
||||
<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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-12">
|
||||
@ -134,20 +137,22 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</app-setting-item>
|
||||
@if (genreSettings) {
|
||||
<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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -155,20 +160,22 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</app-setting-item>
|
||||
@if (tagsSettings) {
|
||||
<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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -217,134 +224,148 @@
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Writer); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.CoverArtist); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Publisher); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Imprint); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Penciller); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Letterer); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Inker); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -352,114 +373,126 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Editor); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Colorist); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Translator); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Character); as settings) {
|
||||
<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]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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);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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Team); as settings) {
|
||||
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);metadata.teamLocked = true" [settings]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<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)" [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>
|
||||
</app-setting-item>
|
||||
@if (getPersonsSettings(PersonRole.Location); as settings) {
|
||||
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location)" [settings]="settings"
|
||||
[(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>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -373,6 +373,7 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
this.tagsSettings.compareFnForAdd = (options: Tag[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.tagsSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
|
||||
|
||||
if (this.metadata.tags) {
|
||||
this.tagsSettings.savedData = this.metadata.tags;
|
||||
@ -404,6 +405,7 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
this.genreSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title };
|
||||
});
|
||||
this.genreSettings.trackByIdentityFn = (index, value) => value.title + (value.id + '');
|
||||
|
||||
if (this.metadata.genres) {
|
||||
this.genreSettings.savedData = this.metadata.genres;
|
||||
@ -460,6 +462,7 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
@ -520,6 +523,7 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
personSettings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, name: title, description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' };
|
||||
});
|
||||
personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + '');
|
||||
|
||||
return personSettings;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
<ng-container *transloco="let t; read: 'card-detail-layout'">
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
|
||||
@if (header.length > 0) {
|
||||
<div class="row mt-2 g-0 pb-2">
|
||||
@ -12,7 +13,7 @@
|
||||
|
||||
<span>
|
||||
{{header}}
|
||||
@if (pagination !== undefined) {
|
||||
@if (pagination) {
|
||||
<span class="badge bg-primary rounded-pill"
|
||||
[attr.aria-label]="t('total-items', {count: pagination.totalItems})">{{pagination.totalItems}}</span>
|
||||
}
|
||||
@ -26,7 +27,7 @@
|
||||
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
|
||||
<div class="viewport-container ms-1" [ngClass]="{'empty': items.length === 0 && !isLoading}">
|
||||
<div class="content-container">
|
||||
<div class="card-container mt-">
|
||||
<div class="card-container">
|
||||
@if (items.length === 0 && !isLoading) {
|
||||
<p><ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container></p>
|
||||
}
|
||||
@ -62,15 +63,17 @@
|
||||
@if (items.length === 0 && !isLoading) {
|
||||
<div class="mx-auto" style="width: 200px;">
|
||||
<p>
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
@if (noDataTemplate) {
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
} @else {
|
||||
{{t('no-data')}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
|
||||
<ng-template #jumpBar>
|
||||
<div class="jump-bar">
|
||||
@for(jumpKey of jumpBarKeysToRender; track jumpKey.key; let i = $index) {
|
||||
|
@ -96,7 +96,7 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
|
||||
@ContentChild('noData') noDataTemplate: TemplateRef<any> | null = null;
|
||||
@ViewChild('.jump-bar') jumpBar!: ElementRef<HTMLDivElement>;
|
||||
|
||||
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
|
||||
|
@ -1,8 +1,10 @@
|
||||
<ng-container *transloco="let t; read: 'download-indicator'">
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{t('progress',{percentage: download.progress})}}
|
||||
@if (download$ | async; as download) {
|
||||
<span class="download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
{{t('progress',{percentage: download.progress})}}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadEvent } from 'src/app/shared/_services/download.service';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
import {Observable} from 'rxjs';
|
||||
import {Download} from 'src/app/shared/_models/download';
|
||||
import {DownloadEvent} from 'src/app/shared/_services/download.service';
|
||||
import {CircularLoaderComponent} from "../../shared/circular-loader/circular-loader.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AsyncPipe} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-indicator',
|
||||
imports: [CommonModule, CircularLoaderComponent, TranslocoDirective],
|
||||
templateUrl: './download-indicator.component.html',
|
||||
styleUrls: ['./download-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-download-indicator',
|
||||
imports: [CircularLoaderComponent, TranslocoDirective, AsyncPipe],
|
||||
templateUrl: './download-indicator.component.html',
|
||||
styleUrls: ['./download-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DownloadIndicatorComponent {
|
||||
|
||||
|
@ -29,6 +29,11 @@
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
<!-- TODO: Come back and figure this out -->
|
||||
{{t('common.no-data')}}
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
}
|
||||
|
||||
|
@ -1,69 +1,80 @@
|
||||
<ng-container *transloco="let t; read: 'metadata-builder'">
|
||||
<ng-container *ngIf="filter">
|
||||
<form [formGroup]="formGroup">
|
||||
<ng-container *ngIf="utilityService.getActiveBreakpoint() === Breakpoint.Desktop; else mobileView">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="comparison">
|
||||
<option *ngFor="let opt of groupOptions" [value]="opt.value">{{opt.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (filter) {
|
||||
<form [formGroup]="formGroup">
|
||||
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')" [disabled]="statementLimit === -1 || (statementLimit > 0 && filter.statements.length >= statementLimit)">
|
||||
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2" *ngFor="let filterStmt of filter.statements; let i = index">
|
||||
<div class="col-md-10">
|
||||
<app-metadata-row-filter [index]="i + 100" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
|
||||
<div class="col-md-1 ms-2">
|
||||
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
|
||||
<i class="fa-solid fa-minus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-rule', {num: i})}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-metadata-row-filter>
|
||||
</div>
|
||||
@if (utilityService.getActiveBreakpoint() === Breakpoint.Desktop) {
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-auto">
|
||||
<select class="form-select" formControlName="comparison">
|
||||
@for (opt of groupOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{opt.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')" [disabled]="statementLimit === -1 || (statementLimit > 0 && filter.statements.length >= statementLimit)">
|
||||
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #mobileView>
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4 col-10">
|
||||
<select class="form-select" formControlName="comparison">
|
||||
<option *ngFor="let opt of groupOptions" [value]="opt.value">{{opt.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
@for (filterStmt of filter.statements; track filterStmt; let i = $index) {
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-10">
|
||||
<app-metadata-row-filter [index]="i + 100" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
|
||||
<div class="col-md-1 ms-2">
|
||||
@if (i < (filter.statements.length - 1) && filter.statements.length > 1) {
|
||||
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule', {num: i})" (click)="removeFilter(i)">
|
||||
<i class="fa-solid fa-minus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-rule', {num: i})}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</app-metadata-row-filter>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4 col-10">
|
||||
<select class="form-select" formControlName="comparison">
|
||||
@for (opt of groupOptions; track opt.value) {
|
||||
<option [value]="opt.value">{{opt.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2 col-1">
|
||||
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
|
||||
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3" *ngFor="let filterStmt of filter.statements; let i = index">
|
||||
<div class="col-md-12">
|
||||
<app-metadata-row-filter [index]="i" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
|
||||
<div class="col-md-1 ms-2 col-1">
|
||||
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)" *ngIf="i < (filter.statements.length - 1) && filter.statements.length > 1">
|
||||
<i class="fa-solid fa-minus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-rule')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</app-metadata-row-filter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</form>
|
||||
</ng-container>
|
||||
<div class="col-md-2 col-1">
|
||||
<button class="btn btn-icon" (click)="addFilter()" [ngbTooltip]="t('add-rule')">
|
||||
<i class="fa fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden" aria-hidden="true">{{t('add-rule')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@for (filterStmt of filter.statements; track filterStmt; let i = $index) {
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<app-metadata-row-filter [index]="i" [preset]="filterStmt" [availableFields]="availableFilterFields" (filterStatement)="updateFilter(i, $event)">
|
||||
<div class="col-md-1 ms-2 col-1">
|
||||
@if (i < (filter.statements.length - 1) && filter.statements.length > 1) {
|
||||
<button type="button" class="btn btn-icon" #removeBtn [ngbTooltip]="t('remove-rule')" (click)="removeFilter(i)">
|
||||
<i class="fa-solid fa-minus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-rule')}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</app-metadata-row-filter>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
}
|
||||
</ng-container>
|
||||
|
@ -1,7 +1,8 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
@ -11,10 +12,8 @@ import {
|
||||
import {MetadataService} from 'src/app/_services/metadata.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {SeriesFilterV2} from 'src/app/_models/metadata/v2/series-filter-v2';
|
||||
import {NgForOf, NgIf, UpperCasePipe} from "@angular/common";
|
||||
import {MetadataFilterRowComponent} from "../metadata-filter-row/metadata-filter-row.component";
|
||||
import {FilterStatement} from "../../../_models/metadata/v2/filter-statement";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {FilterCombination} from "../../../_models/metadata/v2/filter-combination";
|
||||
@ -25,21 +24,17 @@ import {distinctUntilChanged, tap} from "rxjs/operators";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-builder',
|
||||
templateUrl: './metadata-builder.component.html',
|
||||
styleUrls: ['./metadata-builder.component.scss'],
|
||||
imports: [
|
||||
NgIf,
|
||||
MetadataFilterRowComponent,
|
||||
NgForOf,
|
||||
CardActionablesComponent,
|
||||
FormsModule,
|
||||
NgbTooltip,
|
||||
UpperCasePipe,
|
||||
ReactiveFormsModule,
|
||||
TranslocoDirective
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-metadata-builder',
|
||||
templateUrl: './metadata-builder.component.html',
|
||||
styleUrls: ['./metadata-builder.component.scss'],
|
||||
imports: [
|
||||
MetadataFilterRowComponent,
|
||||
FormsModule,
|
||||
NgbTooltip,
|
||||
ReactiveFormsModule,
|
||||
TranslocoDirective
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class MetadataBuilderComponent implements OnInit {
|
||||
|
||||
|
@ -25,7 +25,9 @@ import {FilterComparisonPipe} from "../../../_pipes/filter-comparison.pipe";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {Select2, Select2Option} from "ng-select2-component";
|
||||
import {NgbDate, NgbDateParserFormatter, NgbInputDatepicker, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
|
||||
import {AgeRatingPipe} from "../../../_pipes/age-rating.pipe";
|
||||
|
||||
enum PredicateType {
|
||||
Text = 1,
|
||||
@ -135,6 +137,18 @@ const BooleanComparisons = [
|
||||
})
|
||||
export class MetadataFilterRowComponent implements OnInit {
|
||||
|
||||
protected readonly FilterComparison = FilterComparison;
|
||||
protected readonly PredicateType = PredicateType;
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dateParser = inject(NgbDateParserFormatter);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly collectionTagService = inject(CollectionTagService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
@Input() index: number = 0; // This is only for debugging
|
||||
/**
|
||||
* Slightly misleading as this is the initial state and will be updated on the filterStatement event emitter
|
||||
@ -144,12 +158,6 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
@Output() filterStatement = new EventEmitter<FilterStatement>();
|
||||
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dateParser = inject(NgbDateParserFormatter);
|
||||
|
||||
protected readonly FilterComparison = FilterComparison;
|
||||
|
||||
formGroup: FormGroup = new FormGroup({
|
||||
'comparison': new FormControl<FilterComparison>(FilterComparison.Equal, []),
|
||||
'filterValue': new FormControl<string | number>('', []),
|
||||
@ -159,7 +167,9 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
dropdownOptions$ = of<Select2Option[]>([]);
|
||||
|
||||
loaded: boolean = false;
|
||||
protected readonly PredicateType = PredicateType;
|
||||
private readonly mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
private readonly ageRatingPipe = new AgeRatingPipe();
|
||||
|
||||
|
||||
get UiLabel(): FilterRowUi | null {
|
||||
const field = parseInt(this.formGroup.get('input')!.value, 10) as FilterField;
|
||||
@ -172,8 +182,6 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
return comp === FilterComparison.Contains || comp === FilterComparison.NotContains || comp === FilterComparison.MustContains;
|
||||
}
|
||||
|
||||
constructor(private readonly metadataService: MetadataService, private readonly libraryService: LibraryService,
|
||||
private readonly collectionTagService: CollectionTagService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.formGroup.addControl('input', new FormControl<FilterField>(FilterField.SeriesName, []));
|
||||
@ -272,7 +280,7 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
})));
|
||||
case FilterField.AgeRating:
|
||||
return this.metadataService.getAllAgeRatings().pipe(map(ratings => ratings.map(rating => {
|
||||
return {value: rating.value, label: rating.title}
|
||||
return {value: rating.value, label: this.ageRatingPipe.transform(rating.value)}
|
||||
})));
|
||||
case FilterField.Genres:
|
||||
return this.metadataService.getAllGenres().pipe(map(genres => genres.map(genre => {
|
||||
@ -284,7 +292,7 @@ export class MetadataFilterRowComponent implements OnInit {
|
||||
})));
|
||||
case FilterField.Formats:
|
||||
return of(mangaFormatFilters).pipe(map(statuses => statuses.map(status => {
|
||||
return {value: status.value, label: status.title}
|
||||
return {value: status.value, label: this.mangaFormatPipe.transform(status.value)}
|
||||
})));
|
||||
case FilterField.Libraries:
|
||||
return this.libraryService.getLibraries().pipe(map(libs => libs.map(lib => {
|
||||
|
@ -45,13 +45,16 @@
|
||||
(progress)="updateLoadProgress($event)"
|
||||
(zoomChange)="calcScrollbarNeeded()"
|
||||
(handToolChange)="updateHandTool($event)"
|
||||
(findbarVisibleChange)="updateSearchOpen($event)"
|
||||
>
|
||||
|
||||
</ngx-extended-pdf-viewer>
|
||||
|
||||
@if (scrollMode === ScrollModeType.page && !isLoading) {
|
||||
<div class="left" (click)="prevPage()"></div>
|
||||
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
|
||||
@if (!isSearchOpen) {
|
||||
<div class="left" (click)="prevPage()"></div>
|
||||
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-template #multiToolbar>
|
||||
|
@ -115,6 +115,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
pageLayoutMode: PageViewModeType = 'multiple';
|
||||
scrollMode: ScrollModeType = ScrollModeType.vertical;
|
||||
spreadMode: SpreadType = 'off';
|
||||
isSearchOpen: boolean = false;
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {
|
||||
this.navService.hideNavBar();
|
||||
@ -353,6 +354,11 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
console.log('event.tool', event);
|
||||
}
|
||||
|
||||
updateSearchOpen(event: boolean) {
|
||||
this.isSearchOpen = event;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
prevPage() {
|
||||
this.currentPage--;
|
||||
if (this.currentPage < 0) this.currentPage = 0;
|
||||
|
@ -91,11 +91,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block btn-actions">
|
||||
<button [class]="formGroup.get('edit')?.value ? 'btn btn-primary' : 'btn btn-icon'" (click)="toggleReorder()" [ngbTooltip]="t('reorder-alt')">
|
||||
<i class="fa-solid fa-list-ol" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (isOwnedReadingList) {
|
||||
<div class="col-auto ms-2 d-none d-md-block btn-actions">
|
||||
<button [class]="formGroup.get('edit')?.value ? 'btn btn-primary' : 'btn btn-icon'" (click)="toggleReorder()" [ngbTooltip]="t('reorder-alt')">
|
||||
<i class="fa-solid fa-list-ol" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -13,9 +13,9 @@
|
||||
}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
|
||||
<div class="col-auto text-end align-self-end justify-content-end">
|
||||
@if (showEdit) {
|
||||
<button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit">
|
||||
<button type="button" class="btn btn-icon edit-btn btn-sm btn-alignment" (click)="toggleEditMode()" [disabled]="!canEdit">
|
||||
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
|
||||
</button>
|
||||
}
|
||||
|
@ -20,3 +20,7 @@
|
||||
padding-bottom: 0.5rem; // Align with h6
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
@ -2,7 +2,9 @@ import {
|
||||
AfterContentInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, ContentChild, ElementRef,
|
||||
Component,
|
||||
ContentChild,
|
||||
ElementRef,
|
||||
inject,
|
||||
Input,
|
||||
TemplateRef
|
||||
@ -48,6 +50,9 @@ export class SettingSwitchComponent implements AfterContentInit {
|
||||
const element = this.elementRef.nativeElement;
|
||||
const inputElement = element.querySelector('input');
|
||||
|
||||
// If no id, generate a random id and assign it to the input
|
||||
inputElement.id = crypto.randomUUID();
|
||||
|
||||
if (inputElement && inputElement.id) {
|
||||
this.labelId = inputElement.id;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -14,8 +14,8 @@
|
||||
</h6>
|
||||
</div>
|
||||
|
||||
<div class="col-auto text-end align-self-end justify-content-end edit-btn">
|
||||
<button type="button" class="btn btn-icon btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
|
||||
<div class="col-auto text-end align-self-end justify-content-end">
|
||||
<button type="button" class="btn btn-icon edit-btn btn-sm btn-alignment" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,3 +2,6 @@
|
||||
padding-bottom: 0.5rem; // Align with h6
|
||||
padding-top: 0;
|
||||
}
|
||||
.edit-btn {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
@ -1,11 +1,16 @@
|
||||
<div class="d-flex justify-content-center align-self-center align-items-center icon-and-title"
|
||||
[ngClass]="{'clickable': clickable}" [attr.role]="clickable ? 'button' : ''" (click)="handleClick($event)">
|
||||
<div class="label" *ngIf="label && label.length > 0">
|
||||
{{label}}
|
||||
</div>
|
||||
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
|
||||
[ngClass]="{'clickable': clickable}" [attr.role]="clickable ? 'button' : ''"
|
||||
(click)="handleClick($event)">
|
||||
|
||||
<div class="text">
|
||||
<ng-content></ng-content>
|
||||
@if (label && label.length > 0) {
|
||||
<div class="label">
|
||||
{{label}}
|
||||
</div>
|
||||
}
|
||||
|
||||
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
|
||||
|
||||
<div class="text">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {NgClass} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon-and-title',
|
||||
imports: [CommonModule],
|
||||
templateUrl: './icon-and-title.component.html',
|
||||
styleUrls: ['./icon-and-title.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-icon-and-title',
|
||||
imports: [
|
||||
NgClass
|
||||
],
|
||||
templateUrl: './icon-and-title.component.html',
|
||||
styleUrls: ['./icon-and-title.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class IconAndTitleComponent {
|
||||
/**
|
||||
|
@ -2,30 +2,38 @@
|
||||
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
|
||||
[showRemoveButton]="false">
|
||||
<ng-template #draggableItem let-position="idx" let-item>
|
||||
<app-dashboard-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-dashboard-stream-list-item>
|
||||
<app-dashboard-stream-list-item [item]="item" [position]="position"
|
||||
(hide)="updateVisibility($event, position)"
|
||||
(delete)="delete($event)"
|
||||
/>
|
||||
</ng-template>
|
||||
</app-draggable-ordered-list>
|
||||
|
||||
<h5>Smart Filters</h5>
|
||||
<h5>{{t('smart-filter-title')}}</h5>
|
||||
<form [formGroup]="listForm">
|
||||
<div class="mb-3" *ngIf="smartFilters.length >= 3">
|
||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
|
||||
@if (smartFilters.length >= 3) {
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
<ul class="list-group filter-list">
|
||||
<li class="filter list-group-item" *ngFor="let filter of smartFilters | filter: filterList">
|
||||
{{filter.name}}
|
||||
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
{{t('add')}}
|
||||
</button>
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="smartFilters.length === 0">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
@for (filter of smartFilters | filter: filterList; track filter) {
|
||||
<li class="filter list-group-item">
|
||||
{{filter.name}}
|
||||
<button class="btn btn-icon" (click)="addFilterToStream(filter)">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
{{t('add')}}
|
||||
</button>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="list-group-item">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ng-container>
|
||||
|
@ -1,14 +1,13 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {
|
||||
DraggableOrderedListComponent, IndexUpdateEvent
|
||||
DraggableOrderedListComponent,
|
||||
IndexUpdateEvent
|
||||
} from "../../../reading-list/_components/draggable-ordered-list/draggable-ordered-list.component";
|
||||
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
|
||||
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
|
||||
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||
import {DashboardService} from "../../../_services/dashboard.service";
|
||||
import {FilterService} from "../../../_services/filter.service";
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {forkJoin} from "rxjs";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
@ -17,7 +16,8 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
|
||||
|
||||
@Component({
|
||||
selector: 'app-customize-dashboard-streams',
|
||||
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, ReactiveFormsModule, FilterPipe],
|
||||
imports: [DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective,
|
||||
ReactiveFormsModule, FilterPipe],
|
||||
templateUrl: './customize-dashboard-streams.component.html',
|
||||
styleUrls: ['./customize-dashboard-streams.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -30,6 +30,7 @@ export class CustomizeDashboardStreamsComponent {
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
|
||||
items: DashboardStream[] = [];
|
||||
allSmartFilters: SmartFilter[] = [];
|
||||
smartFilters: SmartFilter[] = [];
|
||||
accessibilityMode: boolean = false;
|
||||
listForm: FormGroup = new FormGroup({
|
||||
@ -54,12 +55,19 @@ export class CustomizeDashboardStreamsComponent {
|
||||
this.accessibilityMode = true;
|
||||
}
|
||||
|
||||
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
|
||||
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
|
||||
this.allSmartFilters = results[1];
|
||||
this.updateSmartFilters();
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
updateSmartFilters() {
|
||||
const smartFilterStreams = new Set(this.items.filter(d => !d.isProvided).map(d => d.name));
|
||||
this.smartFilters = this.allSmartFilters.filter(d => !smartFilterStreams.has(d.name));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
addFilterToStream(filter: SmartFilter) {
|
||||
this.dashboardService.createDashboardStream(filter.id).subscribe(stream => {
|
||||
this.smartFilters = this.smartFilters.filter(d => d.name !== filter.name);
|
||||
@ -79,4 +87,17 @@ export class CustomizeDashboardStreamsComponent {
|
||||
this.dashboardService.updateDashboardStream(this.items[position]).subscribe();
|
||||
}
|
||||
|
||||
delete(item: DashboardStream) {
|
||||
this.dashboardService.deleteSmartFilterStream(item.id).subscribe({
|
||||
next: () => {
|
||||
this.items = this.items.filter(d => d.id !== item.id);
|
||||
this.updateSmartFilters();
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -37,7 +37,10 @@
|
||||
[virtualizeAfter]="virtualizeAfter"
|
||||
>
|
||||
<ng-template #draggableItem let-position="idx" let-item>
|
||||
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
|
||||
<app-sidenav-stream-list-item [item]="item" [position]="position"
|
||||
(hide)="updateVisibility($event, position)"
|
||||
(delete)="delete($event)"
|
||||
/>
|
||||
</ng-template>
|
||||
</app-draggable-ordered-list>
|
||||
</div>
|
||||
|
@ -41,6 +41,7 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
|
||||
items: SideNavStream[] = [];
|
||||
allSmartFilters: SmartFilter[] = [];
|
||||
smartFilters: SmartFilter[] = [];
|
||||
externalSources: ExternalSource[] = [];
|
||||
virtualizeAfter = 100;
|
||||
@ -148,8 +149,8 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||
this.pageOperationsForm.get('accessibilityMode')?.setValue(true);
|
||||
}
|
||||
|
||||
const existingSmartFilterStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
|
||||
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
|
||||
this.allSmartFilters = results[1];
|
||||
this.updateSmartFilters();
|
||||
|
||||
const existingExternalSourceStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.ExternalSource).map(d => d.name));
|
||||
this.externalSources = results[2].filter(d => !existingExternalSourceStreams.has(d.name));
|
||||
@ -157,6 +158,12 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
updateSmartFilters() {
|
||||
const existingSmartFilterStreams = new Set(this.items.filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
|
||||
this.smartFilters = this.allSmartFilters.filter(d => !existingSmartFilterStreams.has(d.name));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
}
|
||||
@ -210,4 +217,17 @@ export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||
this.sideNavService.updateSideNavStream(stream).subscribe();
|
||||
}
|
||||
|
||||
delete(item: SideNavStream) {
|
||||
this.sideNavService.deleteSideNavSmartFilter(item.id).subscribe({
|
||||
next: () => {
|
||||
this.items = this.items.filter(i => i.id !== item.id);
|
||||
this.updateSmartFilters();
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -14,6 +14,13 @@
|
||||
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove')}}</span>
|
||||
</button>
|
||||
|
||||
@if (item.streamType===StreamType.SmartFilter) {
|
||||
<button class="btn btn-icon" (click)="delete.emit(item)">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete')}}</span>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</h5>
|
||||
<div class="meta">
|
||||
|
@ -1,12 +1,14 @@
|
||||
import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output} from '@angular/core';
|
||||
import {APP_BASE_HREF, NgClass} from '@angular/common';
|
||||
import {APP_BASE_HREF, NgClass, NgIf} from '@angular/common';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DashboardStream} from "../../../_models/dashboard/dashboard-stream";
|
||||
import {StreamNamePipe} from "../../../_pipes/stream-name.pipe";
|
||||
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
||||
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard-stream-list-item',
|
||||
imports: [TranslocoDirective, StreamNamePipe, NgClass],
|
||||
imports: [TranslocoDirective, StreamNamePipe, NgClass, NgIf],
|
||||
templateUrl: './dashboard-stream-list-item.component.html',
|
||||
styleUrls: ['./dashboard-stream-list-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -15,5 +17,7 @@ export class DashboardStreamListItemComponent {
|
||||
@Input({required: true}) item!: DashboardStream;
|
||||
@Input({required: true}) position: number = 0;
|
||||
@Output() hide: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
|
||||
@Output() delete: EventEmitter<DashboardStream> = new EventEmitter<DashboardStream>();
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
protected readonly StreamType = StreamType;
|
||||
}
|
||||
|
@ -0,0 +1,39 @@
|
||||
<ng-container *transloco="let t; read:'manage-smart-filters'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">
|
||||
{{t('edit-smart-filter', {name: smartFilter.name | sentenceCase})}}
|
||||
</h4>
|
||||
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="smartFilterForm">
|
||||
|
||||
<div class="m-3">
|
||||
<label for="smart-filter-title" class="form-label">{{t('name-label')}}</label>
|
||||
@if (smartFilterForm.get('name'); as nameControl) {
|
||||
<input id="smart-filter-title" class="form-control" formControlName="name" type="text"
|
||||
[class.is-invalid]="nameControl.invalid && !nameControl.untouched">
|
||||
@if (smartFilterForm.dirty || !smartFilterForm.untouched) {
|
||||
<div id="inviteForm-validations" class="invalid-feedback">
|
||||
@if (nameControl.errors?.required) {
|
||||
<div>{{t('required-field')}}</div>
|
||||
}
|
||||
|
||||
@if (nameControl.errors?.duplicateName) {
|
||||
<div>{{t('filter-name-unique')}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-light" (click)="close()">{{t('cancel')}}</button>
|
||||
<button type="button" class="btn btn-primary" [disabled]="!smartFilterForm.valid" (click)="save()">{{t('save')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,83 @@
|
||||
import {ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {FilterService} from "../../../_services/filter.service";
|
||||
import {debounceTime, distinctUntilChanged, switchMap} from "rxjs/operators";
|
||||
import {of, tap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-smart-filter-modal',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
SentenceCasePipe,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
templateUrl: './edit-smart-filter-modal.component.html',
|
||||
styleUrl: './edit-smart-filter-modal.component.scss'
|
||||
})
|
||||
export class EditSmartFilterModalComponent implements OnInit {
|
||||
|
||||
private readonly modal = inject(NgbActiveModal);
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@Input({required: true}) smartFilter!: SmartFilter;
|
||||
@Input({required: true}) allFilters!: SmartFilter[];
|
||||
|
||||
smartFilterForm: FormGroup = new FormGroup({
|
||||
'name': new FormControl('', [Validators.required]),
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.smartFilterForm.get('name')!.setValue(this.smartFilter.name);
|
||||
|
||||
this.smartFilterForm.get('name')!.valueChanges.pipe(
|
||||
debounceTime(100),
|
||||
distinctUntilChanged(),
|
||||
switchMap(name => {
|
||||
const other = this.allFilters.find(f => {
|
||||
return f.id !== this.smartFilter.id && f.name === name;
|
||||
})
|
||||
return of(other !== undefined)
|
||||
}),
|
||||
tap((exists) => {
|
||||
const isThisSmartFilter = this.smartFilter.name === this.smartFilterForm.get('name')!.value;
|
||||
const empty = (this.smartFilterForm.get('name')!.value as string).trim().length === 0;
|
||||
|
||||
if (!exists || isThisSmartFilter) {
|
||||
if (!empty) {
|
||||
this.smartFilterForm.get('name')!.setErrors(null);
|
||||
}
|
||||
} else {
|
||||
this.smartFilterForm.get('name')!.setErrors({duplicateName: true});
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
|
||||
close(closeVal: boolean = false) {
|
||||
this.modal.close(closeVal);
|
||||
}
|
||||
|
||||
save() {
|
||||
this.smartFilter.name = this.smartFilterForm.get('name')!.value;
|
||||
this.filterService.renameSmartFilter(this.smartFilter).subscribe({
|
||||
next: () => {
|
||||
this.modal.close(true);
|
||||
},
|
||||
error: () => {
|
||||
this.modal.close(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -21,10 +21,17 @@
|
||||
}
|
||||
<a [href]="baseUrl + 'all-series?' + f.filter" [target]="target">{{f.name}}</a>
|
||||
</span>
|
||||
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete')}}</span>
|
||||
</button>
|
||||
<div class="float-end">
|
||||
<button class="btn btn-actions me-2" (click)="editFilter(f)">
|
||||
<i class="fa-solid fa-pencil" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('edit')}}</span>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="list-group-item">
|
||||
|
@ -1,13 +1,15 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input} from '@angular/core';
|
||||
import {FilterService} from "../../../_services/filter.service";
|
||||
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
import {ActionService} from "../../../_services/action.service";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {NgbModal, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
import {EditSmartFilterModalComponent} from "../edit-smart-filter-modal/edit-smart-filter-modal.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-smart-filters',
|
||||
@ -21,6 +23,8 @@ export class ManageSmartFiltersComponent {
|
||||
private readonly filterService = inject(FilterService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly modelService = inject(NgbModal);
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
|
||||
@Input() target: '_self' | '_blank' = '_blank';
|
||||
@ -63,4 +67,16 @@ export class ManageSmartFiltersComponent {
|
||||
});
|
||||
}
|
||||
|
||||
editFilter(f: SmartFilter) {
|
||||
const modalRef = this.modelService.open(EditSmartFilterModalComponent, { size: 'xl', fullscreen: 'md' });
|
||||
modalRef.componentInstance.smartFilter = f;
|
||||
modalRef.componentInstance.allFilters = this.filters;
|
||||
modalRef.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((result) => {
|
||||
if (result) {
|
||||
this.resetFilter();
|
||||
this.loadData();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
|
||||
@if (link === undefined || link.length === 0) {
|
||||
<div class="side-nav-item" [ngClass]="{'closed': ((navService.sideNavCollapsed$ | async)), 'active': highlighted}">
|
||||
<ng-container [ngTemplateOutlet]="inner"></ng-container>
|
||||
@ -26,7 +27,13 @@
|
||||
|
||||
<ng-template #inner>
|
||||
<div class="active-highlight"></div>
|
||||
@if (!noIcon) {
|
||||
@if (editMode) {
|
||||
<span class="phone-hidden" title="{{title}}">
|
||||
<div>
|
||||
<i class="fa fa-grip-vertical drag-handle" aria-hidden="true"></i>
|
||||
</div>
|
||||
</span>
|
||||
} @else if (!noIcon) {
|
||||
<span class="phone-hidden" title="{{title}}">
|
||||
<div>
|
||||
@if (imageUrl !== null && imageUrl !== '') {
|
||||
|
@ -38,3 +38,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
@ -9,11 +9,11 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav-item',
|
||||
imports: [RouterLink, ImageComponent, NgTemplateOutlet, NgClass, AsyncPipe],
|
||||
templateUrl: './side-nav-item.component.html',
|
||||
styleUrls: ['./side-nav-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-side-nav-item',
|
||||
imports: [RouterLink, ImageComponent, NgTemplateOutlet, NgClass, AsyncPipe],
|
||||
templateUrl: './side-nav-item.component.html',
|
||||
styleUrls: ['./side-nav-item.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SideNavItemComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
@ -62,7 +62,13 @@ export class SideNavItemComponent implements OnInit {
|
||||
*/
|
||||
@Input() badgeCount: number | null = -1;
|
||||
|
||||
|
||||
/**
|
||||
* Optional, display item in edit mode (replaces icon with handle)
|
||||
*/
|
||||
@Input() editMode: boolean = false;
|
||||
/**
|
||||
* Comparison Method for route to determine when to highlight item based on route
|
||||
*/
|
||||
@Input() comparisonMethod: 'startsWith' | 'equals' = 'equals';
|
||||
|
||||
|
||||
|
@ -3,19 +3,27 @@
|
||||
<div class="side-nav-container" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async),
|
||||
'hidden': (navService.sideNavVisibility$ | async) === false,
|
||||
'no-donate': (licenseService.hasValidLicense$ | async) === true}">
|
||||
<div class="side-nav">
|
||||
<div class="side-nav" cdkDropList (cdkDropListDropped)="reorderDrop($event)">
|
||||
|
||||
|
||||
<app-side-nav-item icon="fa-home" [title]="t('home')" link="/home/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-home" [title]="t('home')" link="/home/">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v"
|
||||
(actionHandler)="performHomeAction($event)" />
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
|
||||
@if (navStreams$ | async; as streams) {
|
||||
@if (showAll) {
|
||||
<app-side-nav-item icon="fa fa-chevron-left" [title]="t('back')" (click)="showLess()"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa fa-chevron-left"
|
||||
[title]="t(editMode ? 'cancel-edit' : 'back')" (click)="showLess()"></app-side-nav-item>
|
||||
@if (!isReadOnly) {
|
||||
<app-side-nav-item icon="fa-cogs" [title]="t('customize')" link="/settings" [fragment]="SettingsTabId.Customize"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-cogs"
|
||||
[title]="t('customize')" link="/settings"
|
||||
[fragment]="SettingsTabId.Customize"></app-side-nav-item>
|
||||
}
|
||||
@if (streams.length > ItemLimit && (navService.sideNavCollapsed$ | async) === false) {
|
||||
<div class="mb-2 mt-3 ms-2 me-2">
|
||||
@if (streams.length > ItemLimit && (navService.sideNavCollapsed$ | async) === false && !editMode) {
|
||||
<div cdkDrag cdkDragDisabled class="mb-2 mt-3 ms-2 me-2">
|
||||
<label for="filter" class="form-label visually-hidden">{{t('filter-label')}}</label>
|
||||
<div class="form-group position-relative">
|
||||
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
@ -29,8 +37,12 @@
|
||||
@for (navStream of streams | filter: filterLibrary; track navStream.name + navStream.order) {
|
||||
@switch (navStream.streamType) {
|
||||
@case (SideNavStreamType.Library) {
|
||||
<app-side-nav-item [link]="'/library/' + navStream.libraryId + '/'"
|
||||
[icon]="getLibraryTypeIcon(navStream.library!.type)" [imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name" [comparisonMethod]="'startsWith'">
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode"
|
||||
[link]="'/library/' + navStream.libraryId + '/'"
|
||||
[icon]="getLibraryTypeIcon(navStream.library!.type)"
|
||||
[imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name"
|
||||
[comparisonMethod]="'startsWith'">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"
|
||||
(actionHandler)="performAction($event, navStream.library!)"></app-card-actionables>
|
||||
@ -39,35 +51,46 @@
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.AllSeries) {
|
||||
<app-side-nav-item icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-regular fa-rectangle-list" [title]="t('all-series')" link="/all-series/"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.Bookmarks) {
|
||||
<app-side-nav-item icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent" [cdkDragDisabled]="!editMode"
|
||||
[cdkDragData]="navStream" [editMode]="editMode" icon="fa-bookmark" [title]="t('bookmarks')" link="/bookmarks/"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.ReadingLists) {
|
||||
<app-side-nav-item icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode"
|
||||
icon="fa-list-ol" [title]="t('reading-lists')" link="/lists/"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.Collections) {
|
||||
<app-side-nav-item icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-list" [title]="t('collections')" link="/collections/"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.WantToRead) {
|
||||
<app-side-nav-item icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-star" [title]="t('want-to-read')" link="/want-to-read/"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.BrowseAuthors) {
|
||||
<app-side-nav-item icon="fa-users" [title]="t('browse-authors')" link="/browse/authors/"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-users" [title]="t('browse-authors')" link="/browse/authors/"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.SmartFilter) {
|
||||
<app-side-nav-item icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
|
||||
<app-side-nav-item cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[cdkDragDisabled]="!editMode" [cdkDragData]="navStream" [editMode]="editMode" icon="fa-bars-staggered" [title]="navStream.name" link="/all-series" [queryParams]="navStream.smartFilterEncoded"></app-side-nav-item>
|
||||
}
|
||||
|
||||
@case (SideNavStreamType.ExternalSource) {
|
||||
<app-side-nav-item icon="fa-server" [title]="navStream.name" [link]="navStream.externalSource.host + 'login?apiKey=' + navStream.externalSource.apiKey" [external]="true"></app-side-nav-item>
|
||||
<app-side-nav-item [editMode]="editMode" icon="fa-server"
|
||||
cdkDrag cdkDragBoundary=".side-nav-container" cdkDragPreviewContainer="parent"
|
||||
[title]="navStream.name" [link]="navStream.externalSource.host + 'login?apiKey=' + navStream.externalSource.apiKey"
|
||||
[external]="true"></app-side-nav-item>
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,70 +1,71 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {NavigationEnd, Router} from '@angular/router';
|
||||
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { Breakpoint, UtilityService } from '../../../shared/_services/utility.service';
|
||||
import { Library, LibraryType } from '../../../_models/library/library';
|
||||
import { AccountService } from '../../../_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from '../../../_services/action-factory.service';
|
||||
import { ActionService } from '../../../_services/action.service';
|
||||
import { NavService } from '../../../_services/nav.service';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {Breakpoint, UtilityService} from '../../../shared/_services/utility.service';
|
||||
import {Library, LibraryType} from '../../../_models/library/library';
|
||||
import {AccountService} from '../../../_services/account.service';
|
||||
import {Action, ActionFactoryService, ActionItem} from '../../../_services/action-factory.service';
|
||||
import {ActionService} from '../../../_services/action.service';
|
||||
import {NavService} from '../../../_services/nav.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {BehaviorSubject, merge, Observable, of, ReplaySubject, startWith, switchMap} from "rxjs";
|
||||
import {AsyncPipe, NgClass} from "@angular/common";
|
||||
import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
||||
import {WikiLink} from "../../../_models/wiki";
|
||||
import {SettingsTabId} from "../../preference-nav/preference-nav.component";
|
||||
import {LicenseService} from "../../../_services/license.service";
|
||||
import {CdkDrag, CdkDragDrop, CdkDropList} from "@angular/cdk/drag-drop";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
imports: [SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, NgbTooltip, NgClass, AsyncPipe],
|
||||
templateUrl: './side-nav.component.html',
|
||||
styleUrls: ['./side-nav.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-side-nav',
|
||||
imports: [SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, NgbTooltip,
|
||||
NgClass, AsyncPipe, CdkDropList, CdkDrag],
|
||||
templateUrl: './side-nav.component.html',
|
||||
styleUrls: ['./side-nav.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SideNavComponent implements OnInit {
|
||||
|
||||
private readonly router = inject(Router);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
public readonly navService = inject(NavService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly imageService = inject(ImageService);
|
||||
public readonly accountService = inject(AccountService);
|
||||
public readonly licenseService = inject(LicenseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
|
||||
protected readonly WikiLink = WikiLink;
|
||||
protected readonly ItemLimit = 10;
|
||||
protected readonly SideNavStreamType = SideNavStreamType;
|
||||
protected readonly SettingsTabId = SettingsTabId;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
private readonly router = inject(Router);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
protected readonly navService = inject(NavService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly imageService = inject(ImageService);
|
||||
protected readonly accountService = inject(AccountService);
|
||||
protected readonly licenseService = inject(LicenseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly toastr = inject(ToastrService)
|
||||
|
||||
|
||||
cachedData: SideNavStream[] | null = null;
|
||||
actions: ActionItem<Library>[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
homeActions: ActionItem<void>[] = this.actionFactoryService.getSideNavHomeActions(this.handleHomeAction.bind(this));
|
||||
|
||||
filterQuery: string = '';
|
||||
filterLibrary = (stream: SideNavStream) => {
|
||||
return stream.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
showAll: boolean = false;
|
||||
editMode: boolean = false;
|
||||
totalSize = 0;
|
||||
isReadOnly = false;
|
||||
|
||||
@ -88,7 +89,7 @@ export class SideNavComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
|
||||
navStreams$ = merge(
|
||||
navStreams$: Observable<SideNavStream[]> = merge(
|
||||
this.showAll$.pipe(
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
@ -178,12 +179,28 @@ export class SideNavComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async handleHomeAction(action: ActionItem<void>) {
|
||||
switch (action.action) {
|
||||
case Action.Edit:
|
||||
this.showMore(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Library>, library: Library) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, library);
|
||||
}
|
||||
}
|
||||
|
||||
performHomeAction(action: ActionItem<void>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action)
|
||||
}
|
||||
}
|
||||
|
||||
getLibraryTypeIcon(format: LibraryType) {
|
||||
switch (format) {
|
||||
case LibraryType.Book:
|
||||
@ -208,15 +225,31 @@ export class SideNavComponent implements OnInit {
|
||||
this.navService.toggleSideNav();
|
||||
}
|
||||
|
||||
showMore() {
|
||||
showMore(edit: boolean = false) {
|
||||
this.showAllSubject.next(true);
|
||||
this.editMode = edit;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
showLess() {
|
||||
this.filterQuery = '';
|
||||
this.cdRef.markForCheck();
|
||||
this.showAllSubject.next(false);
|
||||
this.editMode = false;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
async reorderDrop($event: CdkDragDrop<any, any, SideNavStream>) {
|
||||
const stream = $event.item.data;
|
||||
// Offset the home, back, and customize button
|
||||
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({
|
||||
next: () => {
|
||||
this.showAllSubject.next(this.showAll);
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
this.toastr.error(translate('errors.generic'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -9,11 +9,15 @@
|
||||
}
|
||||
|
||||
<span class="float-end">
|
||||
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
|
||||
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove')}}</span>
|
||||
</button>
|
||||
</span>
|
||||
<button class="btn btn-icon p-0" (click)="hide.emit(item)">
|
||||
<i class="me-1" [ngClass]="{'fas fa-eye': item.visible, 'fa-solid fa-eye-slash': !item.visible}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove')}}</span>
|
||||
</button>
|
||||
<button *ngIf="item.streamType===SideNavStreamType.SmartFilter" class="btn btn-icon" (click)="delete.emit(item)">
|
||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete')}}</span>
|
||||
</button>
|
||||
</span>
|
||||
</h5>
|
||||
<div class="meta">
|
||||
<div class="ps-1">
|
||||
|
@ -17,6 +17,7 @@ export class SidenavStreamListItemComponent {
|
||||
@Input({required: true}) item!: SideNavStream;
|
||||
@Input({required: true}) position: number = 0;
|
||||
@Output() hide: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
|
||||
@Output() delete: EventEmitter<SideNavStream> = new EventEmitter<SideNavStream>();
|
||||
protected readonly SideNavStreamType = SideNavStreamType;
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
}
|
||||
|
@ -1,44 +1,72 @@
|
||||
<ng-container *transloco="let t; read:'typeahead'">
|
||||
<form [formGroup]="typeaheadForm">
|
||||
<div class="input-group {{hasFocus ? 'open': ''}} {{locked ? 'lock-active' : ''}}">
|
||||
<ng-container *ngIf="settings.showLocked">
|
||||
<span class="input-group-text clickable" (click)="toggleLock($event)"><i class="fa fa-lock" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('locked-field')}}</span>
|
||||
</span>
|
||||
</ng-container>
|
||||
<div class="typeahead-input" [ngClass]="{'disabled': disabled}" (click)="onInputFocus($event)">
|
||||
<app-tag-badge *ngFor="let option of optionSelection.selected(); let i = index" fillStyle="filled">
|
||||
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i, value: typeaheadControl.value }"></ng-container>
|
||||
<i class="fa fa-times" *ngIf="!disabled" (click)="toggleSelection(option)" tabindex="0" [attr.aria-label]="t('close')"></i>
|
||||
</app-tag-badge>
|
||||
<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">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
@if(settings) {
|
||||
<form [formGroup]="typeaheadForm">
|
||||
<div class="input-group {{hasFocus ? 'open': ''}} {{locked ? 'lock-active' : ''}}">
|
||||
@if (settings.showLocked) {
|
||||
<span class="input-group-text clickable" (click)="toggleLock($event)"><i class="fa fa-lock" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('locked-field')}}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div class="typeahead-input" [ngClass]="{'disabled': disabled}" (click)="onInputFocus($event)">
|
||||
@if (optionSelection) {
|
||||
@for (option of optionSelection.selected(); track option; let i = $index) {
|
||||
<app-tag-badge fillStyle="filled">
|
||||
<ng-container [ngTemplateOutlet]="badgeTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: i, value: typeaheadControl.value }"></ng-container>
|
||||
@if (!disabled) {
|
||||
<i class="fa fa-times" (click)="toggleSelection(option)" tabindex="0" [attr.aria-label]="t('close')"></i>
|
||||
}
|
||||
</app-tag-badge>
|
||||
}
|
||||
}
|
||||
|
||||
@if (!disabled) {
|
||||
<input #input [id]="settings.id" type="text" autocomplete="off" formControlName="typeahead">
|
||||
}
|
||||
|
||||
|
||||
@if (isLoadingOptions) {
|
||||
<div class="spinner-border spinner-border-sm {{settings.multiple ? 'close-offset' : ''}}" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!disabled && settings.multiple && (selectedData | async); as selected) {
|
||||
@if (selected.length > 0) {
|
||||
<button class="btn btn-close float-end mt-2" style="font-size: 0.8rem;" (click)="clearSelections(true);$event.stopPropagation()"></button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<ng-container *ngIf="!disabled && settings.multiple && (selectedData | async) as selected">
|
||||
<button class="btn btn-close float-end mt-2" *ngIf="selected.length > 0" style="font-size: 0.8rem;" (click)="clearSelections(true);$event.stopPropagation()"></button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="filteredOptions | async as options">
|
||||
<div class="dropdown" *ngIf="hasFocus" [@slideFromTop]="hasFocus">
|
||||
<ul class="list-group results" #results>
|
||||
<li *ngIf="showAddItem"
|
||||
class="list-group-item add-item" role="option" (mouseenter)="focusedIndex = 0; updateHighlight();" (click)="addNewItem(typeaheadControl.value)">
|
||||
{{t('add-item', {item: typeaheadControl.value})}}
|
||||
</li>
|
||||
<li *ngFor="let option of options; let index = index; trackBy: settings.trackByIdentityFn" (click)="handleOptionClick(option)"
|
||||
class="list-group-item" role="option" [attr.data-index]="index"
|
||||
(mouseenter)="focusedIndex = index + (showAddItem ? 1 : 0); updateHighlight();">
|
||||
<ng-container [ngTemplateOutlet]="optionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index, value: typeaheadControl.value }"></ng-container>
|
||||
</li>
|
||||
<li *ngIf="options.length === 0 && !showAddItem" class="list-group-item no-hover" role="status">
|
||||
{{t('no-data')}}{{settings.addIfNonExisting ? t('add-custom-item') : ''}}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
</form>
|
||||
@if (filteredOptions | async; as options) {
|
||||
@if (hasFocus) {
|
||||
<div class="dropdown" [@slideFromTop]="hasFocus">
|
||||
<ul class="list-group results" #results>
|
||||
@if (showAddItem) {
|
||||
<li class="list-group-item add-item" role="option" (mouseenter)="focusedIndex = 0; updateHighlight();" (click)="addNewItem(typeaheadControl.value)">
|
||||
{{t('add-item', {item: typeaheadControl.value})}}
|
||||
</li>
|
||||
}
|
||||
|
||||
@for(option of options; track settings.trackByIdentityFn(index, option); let index = $index) {
|
||||
<li (click)="handleOptionClick(option)"
|
||||
class="list-group-item" role="option" [attr.data-index]="index"
|
||||
(mouseenter)="focusedIndex = index + (showAddItem ? 1 : 0); updateHighlight();">
|
||||
{{settings.trackByIdentityFn(index, option)}}
|
||||
<ng-container [ngTemplateOutlet]="optionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index, value: typeaheadControl.value }"></ng-container>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (options.length === 0 && !showAddItem) {
|
||||
<li class="list-group-item no-hover" role="listitem">
|
||||
{{t('no-data')}}{{settings.addIfNonExisting ? t('add-custom-item') : ''}}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</ng-container>
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { trigger, state, style, transition, animate } from '@angular/animations';
|
||||
import {CommonModule, DOCUMENT} from '@angular/common';
|
||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||
import {AsyncPipe, DOCUMENT, NgClass, NgTemplateOutlet} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ContentChild, DestroyRef,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
@ -19,10 +20,10 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import { Observable, ReplaySubject } from 'rxjs';
|
||||
import { auditTime, filter, map, shareReplay, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from '../_models/typeahead-settings';
|
||||
import {Observable, ReplaySubject} from 'rxjs';
|
||||
import {auditTime, filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators';
|
||||
import {KEY_CODES} from 'src/app/shared/_services/utility.service';
|
||||
import {TypeaheadSettings} from '../_models/typeahead-settings';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
@ -32,23 +33,23 @@ import {SelectionModel} from "../_models/selection-model";
|
||||
const ANIMATION_SPEED = 200;
|
||||
|
||||
@Component({
|
||||
selector: 'app-typeahead',
|
||||
imports: [CommonModule, TagBadgeComponent, ReactiveFormsModule, TranslocoDirective],
|
||||
templateUrl: './typeahead.component.html',
|
||||
styleUrls: ['./typeahead.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger('slideFromTop', [
|
||||
state('in', style({ height: '0px' })),
|
||||
transition('void => *', [
|
||||
style({ height: '100%', overflow: 'auto' }),
|
||||
animate(ANIMATION_SPEED)
|
||||
]),
|
||||
transition('* => void', [
|
||||
animate(ANIMATION_SPEED, style({ height: '0px' })),
|
||||
])
|
||||
])
|
||||
]
|
||||
selector: 'app-typeahead',
|
||||
imports: [TagBadgeComponent, ReactiveFormsModule, TranslocoDirective, AsyncPipe, NgTemplateOutlet, NgClass],
|
||||
templateUrl: './typeahead.component.html',
|
||||
styleUrls: ['./typeahead.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger('slideFromTop', [
|
||||
state('in', style({ height: '0px' })),
|
||||
transition('void => *', [
|
||||
style({ height: '100%', overflow: 'auto' }),
|
||||
animate(ANIMATION_SPEED)
|
||||
]),
|
||||
transition('* => void', [
|
||||
animate(ANIMATION_SPEED, style({ height: '0px' })),
|
||||
])
|
||||
])
|
||||
]
|
||||
})
|
||||
export class TypeaheadComponent implements OnInit {
|
||||
/**
|
||||
@ -76,7 +77,7 @@ export class TypeaheadComponent implements OnInit {
|
||||
// eslint-disable-next-line @angular-eslint/no-output-on-prefix
|
||||
@Output() onUnlock = new EventEmitter<void>();
|
||||
@Output() lockedChange = new EventEmitter<boolean>();
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
||||
|
||||
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
|
||||
@ -93,7 +94,11 @@ export class TypeaheadComponent implements OnInit {
|
||||
typeaheadControl!: FormControl;
|
||||
typeaheadForm!: FormGroup;
|
||||
|
||||
constructor(private renderer2: Renderer2, @Inject(DOCUMENT) private document: Document, private readonly cdRef: ChangeDetectorRef) { }
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly renderer2 = inject(Renderer2);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.reset.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((resetToEmpty: boolean) => {
|
||||
@ -118,7 +123,8 @@ export class TypeaheadComponent implements OnInit {
|
||||
}
|
||||
|
||||
if (this.settings.trackByIdentityFn === undefined) {
|
||||
this.settings.trackByIdentityFn = (index, value) => value;
|
||||
console.warn('No trackby function provided, falling back to an expensive implementation')
|
||||
this.settings.trackByIdentityFn = (_, value) => value;
|
||||
}
|
||||
|
||||
if (this.settings.hasOwnProperty('formControl') && this.settings.formControl) {
|
||||
@ -222,9 +228,9 @@ export class TypeaheadComponent implements OnInit {
|
||||
}
|
||||
case KEY_CODES.ENTER:
|
||||
{
|
||||
this.document.querySelectorAll('.list-group-item').forEach((item, index) => {
|
||||
this.document.querySelectorAll('.list-group-item').forEach((item, _) => {
|
||||
if (item.classList.contains('active')) {
|
||||
this.filteredOptions.pipe(take(1)).subscribe((opts: any[]) => {
|
||||
this.filteredOptions.pipe(take(1)).subscribe((_: any[]) => {
|
||||
// This isn't giving back the filtered array, but everything
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@ -413,7 +419,7 @@ export class TypeaheadComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
toggleLock(event: any) {
|
||||
toggleLock(_: any) {
|
||||
if (this.disabled) return;
|
||||
this.locked = !this.locked;
|
||||
this.lockedChange.emit(this.locked);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import {Observable} from 'rxjs';
|
||||
import {FormControl} from '@angular/forms';
|
||||
|
||||
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
|
||||
|
||||
@ -28,7 +28,7 @@ export class TypeaheadSettings<T> {
|
||||
*/
|
||||
savedData!: T[] | T;
|
||||
/**
|
||||
* Function to compare the elements. Should return all elements that fit the matching criteria.
|
||||
* Function to compare the elements. Should return all elements that fit the matching criteria.
|
||||
* This is only used with non-Observable based fetchFn, but must be defined for all uses of typeahead.
|
||||
*/
|
||||
compareFn!: ((optionList: T[], filter: string) => T[]);
|
||||
@ -37,12 +37,12 @@ export class TypeaheadSettings<T> {
|
||||
*/
|
||||
compareFnForAdd!: ((optionList: T[], filter: string) => T[]);
|
||||
/**
|
||||
* Function which is used for comparing objects when keeping track of state.
|
||||
* Function which is used for comparing objects when keeping track of state.
|
||||
* Useful over shallow equal when you have image urls that have random numbers on them.
|
||||
*/
|
||||
*/
|
||||
selectionCompareFn?: SelectionCompareFn<T>;
|
||||
/**
|
||||
* Function to fetch the data from the server. If data is mainatined in memory, wrap in an observable.
|
||||
* Function to fetch the data from the server. If data is maintained in memory, wrap in an observable.
|
||||
*/
|
||||
fetchFn!: (filter: string) => Observable<T[]>;
|
||||
/**
|
||||
@ -50,7 +50,7 @@ export class TypeaheadSettings<T> {
|
||||
*/
|
||||
minCharacters: number = 1;
|
||||
/**
|
||||
* Optional form Control to tie model to.
|
||||
* Optional form Control to tie model to.
|
||||
*/
|
||||
formControl?: FormControl;
|
||||
/**
|
||||
@ -68,5 +68,5 @@ export class TypeaheadSettings<T> {
|
||||
/**
|
||||
* An optional, but recommended trackby identity function to help Angular render the list better
|
||||
*/
|
||||
trackByIdentityFn!: (index: number, value: T) => T;
|
||||
}
|
||||
trackByIdentityFn!: (index: number, value: T) => string;
|
||||
}
|
||||
|
@ -9,12 +9,12 @@
|
||||
<ng-container>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('locale-label')" [subtitle]="t('locale-tooltip')">
|
||||
<app-setting-item [title]="t('locale-label')" [subtitle]="t('locale-tooltip')" labelId="locale">
|
||||
<ng-template #view>
|
||||
{{Locale | titlecase}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="global-header" formControlName="locale">
|
||||
<select class="form-select" aria-describedby="global-header" formControlName="locale" id="locale">
|
||||
@for(opt of locales; track opt.renderName) {
|
||||
<option [value]="opt.fileName">{{opt.renderName | titlecase}} ({{opt.translationCompletion | number:'1.0-1'}}%)</option>
|
||||
}
|
||||
@ -27,7 +27,7 @@
|
||||
<app-setting-switch [title]="t('blur-unread-summaries-label')" [subtitle]="t('blur-unread-summaries-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="blur-unread-summaries"
|
||||
formControlName="blurUnreadSummaries" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -40,7 +40,7 @@
|
||||
<app-setting-switch [title]="t('prompt-on-download-label')" [subtitle]="t('prompt-on-download-tooltip', {size: '100'})">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="prompt-on-download"
|
||||
formControlName="promptForDownloadSize" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -52,7 +52,7 @@
|
||||
<app-setting-switch [title]="t('disable-animations-label')" [subtitle]="t('disable-animations-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="disable-animations"
|
||||
formControlName="noTransitions" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -64,7 +64,7 @@
|
||||
<app-setting-switch [title]="t('collapse-series-relationships-label')" [subtitle]="t('collapse-series-relationships-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="collapse-series-relationships"
|
||||
formControlName="collapseSeriesRelationships" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -76,7 +76,7 @@
|
||||
<app-setting-switch [title]="t('share-series-reviews-label')" [subtitle]="t('share-series-reviews-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="share-series-reviews"
|
||||
formControlName="shareReviews" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -96,7 +96,8 @@
|
||||
<app-setting-switch [title]="t('anilist-scrobbling-label')" [subtitle]="t('anilist-scrobbling-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="setting-anilist-scrobbling" type="checkbox" class="form-check-input" formControlName="aniListScrobblingEnabled">
|
||||
<input id="setting-anilist-scrobbling" type="checkbox"
|
||||
class="form-check-input" formControlName="aniListScrobblingEnabled">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
@ -245,7 +246,7 @@
|
||||
<app-setting-switch [title]="t('emulate-comic-book-label')" [subtitle]="t('emulate-comic-book-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="emulate-comic-book"
|
||||
formControlName="emulateBook" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -257,7 +258,7 @@
|
||||
<app-setting-switch [title]="t('swipe-to-paginate-label')" [subtitle]="t('swipe-to-paginate-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="swipe-to-paginate"
|
||||
formControlName="swipeToPaginate" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -269,7 +270,7 @@
|
||||
<app-setting-switch [title]="t('allow-auto-webtoon-reader-label')" [subtitle]="t('allow-auto-webtoon-reader-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="allow-auto-webtoon-reader"
|
||||
formControlName="allowAutomaticWebtoonReaderDetection" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
@ -286,7 +287,7 @@
|
||||
<app-setting-switch [title]="t('tap-to-paginate-label')" [subtitle]="t('tap-to-paginate-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
<input type="checkbox" role="switch" id="tap-to-paginate"
|
||||
formControlName="bookReaderTapToPaginate" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
|
@ -1074,8 +1074,10 @@
|
||||
"donate": "Donate",
|
||||
"donate-tooltip": "You can remove this by subscribing to Kavita+",
|
||||
"back": "Back",
|
||||
"cancel-edit": "Cancel Edit",
|
||||
"more": "More",
|
||||
"customize": "{{settings.customize}}"
|
||||
"customize": "{{settings.customize}}",
|
||||
"edit": "{{common.edit}}"
|
||||
},
|
||||
|
||||
"browse-authors": {
|
||||
@ -1217,7 +1219,8 @@
|
||||
|
||||
"card-detail-layout": {
|
||||
"total-items": "{{count}} total items",
|
||||
"jumpkey-count": "{{count}} Series"
|
||||
"jumpkey-count": "{{count}} Series",
|
||||
"no-data": "{{common.no-data}}"
|
||||
},
|
||||
|
||||
"card-item": {
|
||||
@ -1608,13 +1611,13 @@
|
||||
"change-password-alt": "Change Password {{user}}",
|
||||
"resend": "Resend",
|
||||
"setup": "Setup",
|
||||
"admin": "Admin",
|
||||
"last-active-header": "Last Active",
|
||||
"roles-header": "Roles",
|
||||
"name-header": "Name",
|
||||
"none": "None",
|
||||
"never": "Never",
|
||||
"online-now-tooltip": "Online Now",
|
||||
"all-libraries": "All Libraries",
|
||||
"too-many-libraries": "A lot",
|
||||
"sharing-header": "Sharing",
|
||||
"no-data": "There are no other users.",
|
||||
@ -1623,6 +1626,17 @@
|
||||
"pending-tooltip": "This user has not validated their email"
|
||||
},
|
||||
|
||||
"role-localized-pipe": {
|
||||
"admin": "Admin",
|
||||
"download": "Download",
|
||||
"change-password": "Change Password",
|
||||
"bookmark": "Bookmark",
|
||||
"change-restriction": "Change Restriction",
|
||||
"login": "Login",
|
||||
"read-only": "Read Only",
|
||||
"promote": "Promote"
|
||||
},
|
||||
|
||||
"edit-collection-tags": {
|
||||
"title": "Edit {{collectionName}} Collection",
|
||||
"required-field": "{{validation.required-field}}",
|
||||
@ -1739,6 +1753,7 @@
|
||||
|
||||
"stream-list-item": {
|
||||
"remove": "{{common.remove}}",
|
||||
"delete": "{{common.delete}}",
|
||||
"load-filter": "Load Filter",
|
||||
"provided": "Provided",
|
||||
"smart-filter": "Smart Filter",
|
||||
@ -2392,7 +2407,8 @@
|
||||
"save": "{{common.save}}",
|
||||
"add": "{{common.add}}",
|
||||
"filter": "{{common.filter}}",
|
||||
"clear": "{{common.clear}}"
|
||||
"clear": "{{common.clear}}",
|
||||
"smart-filter-title": "{{customize-dashboard-modal.title-smart-filters}}"
|
||||
},
|
||||
|
||||
"customize-sidenav-streams": {
|
||||
@ -2423,7 +2439,15 @@
|
||||
"no-data": "No Smart Filters created",
|
||||
"filter": "{{common.filter}}",
|
||||
"clear": "{{common.clear}}",
|
||||
"errored": "There is an encoding error in the filter. You need to recreate it."
|
||||
"errored": "There is an encoding error in the filter. You need to recreate it.",
|
||||
"cancel": "{{common.cancel}}",
|
||||
"close": "{{common.close}}",
|
||||
"edit": "{{common.edit}}",
|
||||
"save": "{{common.save}}",
|
||||
"edit-smart-filter": "Edit {{name}}",
|
||||
"name-label": "Name",
|
||||
"required-field": "Smart Filters need a name",
|
||||
"filter-name-unique": "Smart Filter names must be unique"
|
||||
},
|
||||
|
||||
"edit-external-source-item": {
|
||||
@ -2509,6 +2533,14 @@
|
||||
"is-empty": "Is Empty"
|
||||
},
|
||||
|
||||
"log-level-pipe": {
|
||||
"debug": "Debug",
|
||||
"information": "Information",
|
||||
"trace": "Trace",
|
||||
"warning": "Warning",
|
||||
"critical": "Critical"
|
||||
},
|
||||
|
||||
"confirm": {
|
||||
"alert": "Alert",
|
||||
"confirm": "Confirm",
|
||||
@ -2723,7 +2755,8 @@
|
||||
"title": "Actions",
|
||||
"copy-settings": "Copy Settings From",
|
||||
"match": "Match",
|
||||
"match-tooltip": "Match Series with Kavita+ manually"
|
||||
"match-tooltip": "Match Series with Kavita+ manually",
|
||||
"reorder": "Reorder"
|
||||
},
|
||||
|
||||
"preferences": {
|
||||
|
@ -1,17 +1,50 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Translation, TranslocoLoader} from "@jsverse/transloco";
|
||||
import cacheBusting from 'i18n-cache-busting.json'; // allowSyntheticDefaultImports must be true
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class HttpLoader implements TranslocoLoader {
|
||||
private loadedVersions: { [key: string]: string } = {};
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getTranslation(langPath: string) {
|
||||
const tokens = langPath.split('/');
|
||||
const langCode = tokens[tokens.length - 1];
|
||||
const url = `assets/langs/${langCode}.json?v=${(cacheBusting as { [key: string]: string })[langCode]}`;
|
||||
console.log('loading locale: ', url);
|
||||
return this.http.get<Translation>(url);
|
||||
|
||||
const currentHash = (cacheBusting as { [key: string]: string })[langCode] || 'en';
|
||||
|
||||
// Check if we've loaded this version before
|
||||
const cachedVersion = this.loadedVersions[langCode];
|
||||
|
||||
// If the hash has changed, force a new request and clear local storage cache
|
||||
if (cachedVersion && cachedVersion !== currentHash) {
|
||||
console.log(`Translation hash changed for ${langCode}. Clearing cache.`);
|
||||
this.clearTranslocoCache(langCode);
|
||||
}
|
||||
|
||||
// Store the version we're loading
|
||||
this.loadedVersions[langCode] = currentHash;
|
||||
|
||||
const url = `assets/langs/${langCode}.json?v=${currentHash}`;
|
||||
console.log('Loading locale:', url);
|
||||
|
||||
// Add cache control headers to prevent browser caching
|
||||
return this.http.get<Translation>(url, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears Transloco cache for a specific language
|
||||
*/
|
||||
private clearTranslocoCache(langCode: string): void {
|
||||
localStorage.removeItem('translocoLang');
|
||||
localStorage.removeItem('@transloco/translations');
|
||||
localStorage.removeItem('@transloco/translations/timestamp');
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +1,25 @@
|
||||
/// <reference types="@angular/localize" />
|
||||
import {
|
||||
APP_INITIALIZER, ApplicationConfig,
|
||||
importProvidersFrom,
|
||||
|
||||
} from '@angular/core';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { NgCircleProgressModule } from 'ng-circle-progress';
|
||||
import { ToastrModule } from 'ngx-toastr';
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { AppRoutingModule } from './app/app-routing.module';
|
||||
import { Title, BrowserModule, bootstrapApplication } from '@angular/platform-browser';
|
||||
import { JwtInterceptor } from './app/_interceptors/jwt.interceptor';
|
||||
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
|
||||
import { HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
provideTransloco, TranslocoConfig,
|
||||
TranslocoService
|
||||
} from "@jsverse/transloco";
|
||||
import {APP_INITIALIZER, ApplicationConfig, importProvidersFrom,} from '@angular/core';
|
||||
import {AppComponent} from './app/app.component';
|
||||
import {NgCircleProgressModule} from 'ng-circle-progress';
|
||||
import {ToastrModule} from 'ngx-toastr';
|
||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import {AppRoutingModule} from './app/app-routing.module';
|
||||
import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser';
|
||||
import {JwtInterceptor} from './app/_interceptors/jwt.interceptor';
|
||||
import {ErrorInterceptor} from './app/_interceptors/error.interceptor';
|
||||
import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
|
||||
import {provideTransloco, TranslocoConfig, TranslocoService} from "@jsverse/transloco";
|
||||
import {environment} from "./environments/environment";
|
||||
import {HttpLoader} from "./httpLoader";
|
||||
import {
|
||||
provideTranslocoPersistLang,
|
||||
} from "@jsverse/transloco-persist-lang";
|
||||
import {AccountService} from "./app/_services/account.service";
|
||||
import {switchMap} from "rxjs";
|
||||
import {provideTranslocoLocale} from "@jsverse/transloco-locale";
|
||||
import {provideTranslocoPersistTranslations} from "@jsverse/transloco-persist-translations";
|
||||
import {LazyLoadImageModule} from "ng-lazyload-image";
|
||||
import {getSaver, SAVER} from "./app/_providers/saver.provider";
|
||||
import {distinctUntilChanged} from "rxjs/operators";
|
||||
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
|
||||
import {provideTranslocoDefaultLocale} from "@jsverse/transloco-locale/lib/transloco-locale.providers";
|
||||
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
|
||||
import {HttpLoader} from "./httpLoader";
|
||||
|
||||
const disableAnimations = !('animate' in document.documentElement);
|
||||
|
||||
@ -145,12 +134,7 @@ bootstrapApplication(AppComponent, {
|
||||
provideTranslocoPersistTranslations({
|
||||
loader: HttpLoader,
|
||||
storage: { useValue: localStorage },
|
||||
ttl: 604800
|
||||
}),
|
||||
provideTranslocoPersistLang({
|
||||
storage: {
|
||||
useValue: localStorage,
|
||||
},
|
||||
ttl: environment.production ? 129600 : 0 // 1.5 days in seconds for prod
|
||||
}),
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
|
||||
|
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