A boatload of Bugs (#3704)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-04-05 15:52:01 -05:00 committed by GitHub
parent ea9b7ad0d1
commit 37734554ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
102 changed files with 2051 additions and 1115 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -162,4 +162,10 @@ public class TokenService : ITokenService
{
return !JwtHelper.IsTokenValid(token);
}
public static DateTime GetTokenExpiry(string? token)
{
return JwtHelper.GetTokenExpiry(token);
}
}

View File

@ -11,6 +11,9 @@ trim_trailing_whitespace = true
[*.json]
indent_size = 2
[en.json]
indent_size = 4
[*.html]
indent_size = 2

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,3 +20,7 @@
padding-bottom: 0.5rem; // Align with h6
padding-top: 0;
}
.edit-btn {
color: var(--primary-color);
}

View File

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

View File

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

View File

@ -2,3 +2,6 @@
padding-bottom: 0.5rem; // Align with h6
padding-top: 0;
}
.edit-btn {
color: var(--primary-color);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,3 +38,7 @@
}
}
}
.drag-handle {
cursor: grab;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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