mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-03 05:34:21 -04:00
IsEmpty Filter and other small fixes (#3142)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
e574caf7eb
commit
07a36176de
@ -250,6 +250,7 @@ public class ParsingTests
|
|||||||
[InlineData("@recycle/Love Hina/", true)]
|
[InlineData("@recycle/Love Hina/", true)]
|
||||||
[InlineData("E:/Test/__MACOSX/Love Hina/", true)]
|
[InlineData("E:/Test/__MACOSX/Love Hina/", true)]
|
||||||
[InlineData("E:/Test/.caltrash/Love Hina/", true)]
|
[InlineData("E:/Test/.caltrash/Love Hina/", true)]
|
||||||
|
[InlineData("E:/Test/.yacreaderlibrary/Love Hina/", true)]
|
||||||
public void HasBlacklistedFolderInPathTest(string inputPath, bool expected)
|
public void HasBlacklistedFolderInPathTest(string inputPath, bool expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath));
|
Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath));
|
||||||
|
@ -12,10 +12,10 @@
|
|||||||
<LangVersion>latestmajor</LangVersion>
|
<LangVersion>latestmajor</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
|
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
|
||||||
<!-- <Delete Files="../openapi.json" />-->
|
<Delete Files="../openapi.json" />
|
||||||
<!-- <Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
|
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
|
||||||
<!-- </Target>-->
|
</Target>
|
||||||
|
|
||||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||||
<DebugSymbols>false</DebugSymbols>
|
<DebugSymbols>false</DebugSymbols>
|
||||||
|
@ -7,6 +7,7 @@ using API.Extensions;
|
|||||||
using API.Services;
|
using API.Services;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
|
|
||||||
@ -31,16 +32,17 @@ public class CblController : BaseApiController
|
|||||||
/// If this returns errors, the cbl will always be rejected by Kavita.
|
/// If this returns errors, the cbl will always be rejected by Kavita.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cbl">FormBody with parameter name of cbl</param>
|
/// <param name="cbl">FormBody with parameter name of cbl</param>
|
||||||
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
/// <param name="useComicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("validate")]
|
[HttpPost("validate")]
|
||||||
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromQuery] bool comicVineMatching = false)
|
[SwaggerIgnore]
|
||||||
|
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromQuery] bool useComicVineMatching = false)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cblReadingList = await SaveAndLoadCblFile(cbl);
|
var cblReadingList = await SaveAndLoadCblFile(cbl);
|
||||||
var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, comicVineMatching);
|
var importSummary = await _readingListService.ValidateCblFile(userId, cblReadingList, useComicVineMatching);
|
||||||
importSummary.FileName = cbl.FileName;
|
importSummary.FileName = cbl.FileName;
|
||||||
return Ok(importSummary);
|
return Ok(importSummary);
|
||||||
}
|
}
|
||||||
@ -82,16 +84,17 @@ public class CblController : BaseApiController
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cbl">FormBody with parameter name of cbl</param>
|
/// <param name="cbl">FormBody with parameter name of cbl</param>
|
||||||
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
|
/// <param name="dryRun">If true, will only emulate the import but not perform. This should be done to preview what will happen</param>
|
||||||
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
/// <param name="useComicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("import")]
|
[HttpPost("import")]
|
||||||
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool comicVineMatching = false)
|
[SwaggerIgnore]
|
||||||
|
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
var cblReadingList = await SaveAndLoadCblFile(cbl);
|
var cblReadingList = await SaveAndLoadCblFile(cbl);
|
||||||
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, comicVineMatching);
|
var importSummary = await _readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, useComicVineMatching);
|
||||||
importSummary.FileName = cbl.FileName;
|
importSummary.FileName = cbl.FileName;
|
||||||
|
|
||||||
return Ok(importSummary);
|
return Ok(importSummary);
|
||||||
|
@ -19,6 +19,7 @@ using API.Services.Tasks.Scanner;
|
|||||||
using API.SignalR;
|
using API.SignalR;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using EasyCaching.Core;
|
using EasyCaching.Core;
|
||||||
|
using Hangfire;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -132,13 +133,19 @@ public class LibraryController : BaseApiController
|
|||||||
|
|
||||||
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
|
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
|
||||||
|
|
||||||
await _libraryWatcher.RestartWatching();
|
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
|
||||||
await _taskScheduler.ScanLibrary(library.Id);
|
|
||||||
|
if (library.FolderWatching)
|
||||||
|
{
|
||||||
|
await _libraryWatcher.RestartWatching();
|
||||||
|
}
|
||||||
|
|
||||||
|
BackgroundJob.Enqueue(() => _taskScheduler.ScanLibrary(library.Id, false));
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||||
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
|
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
|
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
|
||||||
MessageFactory.SideNavUpdateEvent(User.GetUserId()), false);
|
MessageFactory.SideNavUpdateEvent(User.GetUserId()), false);
|
||||||
await _libraryCacheProvider.RemoveByPrefixAsync(CacheKey);
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,7 +416,7 @@ public class LibraryController : BaseApiController
|
|||||||
_taskScheduler.CleanupChapters(chapterIds);
|
_taskScheduler.CleanupChapters(chapterIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _libraryWatcher.RestartWatching();
|
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
|
||||||
|
|
||||||
foreach (var seriesId in seriesIds)
|
foreach (var seriesId in seriesIds)
|
||||||
{
|
{
|
||||||
@ -496,16 +503,17 @@ public class LibraryController : BaseApiController
|
|||||||
_unitOfWork.LibraryRepository.Update(library);
|
_unitOfWork.LibraryRepository.Update(library);
|
||||||
|
|
||||||
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update"));
|
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(userId, "generic-library-update"));
|
||||||
|
|
||||||
|
if (folderWatchingUpdate || originalFoldersCount != dto.Folders.Count() || typeUpdate)
|
||||||
|
{
|
||||||
|
BackgroundJob.Enqueue(() => _libraryWatcher.RestartWatching());
|
||||||
|
}
|
||||||
|
|
||||||
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
|
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
|
||||||
{
|
{
|
||||||
await _libraryWatcher.RestartWatching();
|
|
||||||
await _taskScheduler.ScanLibrary(library.Id);
|
await _taskScheduler.ScanLibrary(library.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (folderWatchingUpdate)
|
|
||||||
{
|
|
||||||
await _libraryWatcher.RestartWatching();
|
|
||||||
}
|
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||||
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
|
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
|
||||||
|
|
||||||
|
@ -873,6 +873,7 @@ public class OpdsController : BaseApiController
|
|||||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
|
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
|
||||||
|
|
||||||
var chapterDict = new Dictionary<int, short>();
|
var chapterDict = new Dictionary<int, short>();
|
||||||
|
var fileDict = new Dictionary<int, short>();
|
||||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||||
foreach (var volume in seriesDetail.Volumes)
|
foreach (var volume in seriesDetail.Volumes)
|
||||||
{
|
{
|
||||||
@ -881,12 +882,14 @@ public class OpdsController : BaseApiController
|
|||||||
foreach (var chapter in chaptersForVolume)
|
foreach (var chapter in chaptersForVolume)
|
||||||
{
|
{
|
||||||
var chapterId = chapter.Id;
|
var chapterId = chapter.Id;
|
||||||
if (chapterDict.ContainsKey(chapterId)) continue;
|
if (!chapterDict.TryAdd(chapterId, 0)) continue;
|
||||||
|
|
||||||
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||||
foreach (var mangaFile in chapter.Files)
|
foreach (var mangaFile in chapter.Files)
|
||||||
{
|
{
|
||||||
chapterDict.Add(chapterId, 0);
|
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
|
||||||
|
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
|
||||||
|
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||||
chapterDto, apiKey, prefix, baseUrl));
|
chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
@ -905,6 +908,8 @@ public class OpdsController : BaseApiController
|
|||||||
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
|
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
|
||||||
|
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||||
chapterDto, apiKey, prefix, baseUrl));
|
chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
@ -916,6 +921,9 @@ public class OpdsController : BaseApiController
|
|||||||
var chapterDto = _mapper.Map<ChapterDto>(special);
|
var chapterDto = _mapper.Map<ChapterDto>(special);
|
||||||
foreach (var mangaFile in files)
|
foreach (var mangaFile in files)
|
||||||
{
|
{
|
||||||
|
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
|
||||||
|
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
|
||||||
|
|
||||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
|
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||||
chapterDto, apiKey, prefix, baseUrl));
|
chapterDto, apiKey, prefix, baseUrl));
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
|
|||||||
}
|
}
|
||||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||||
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
|
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
|
||||||
|
|
||||||
return new UserDto
|
return new UserDto
|
||||||
{
|
{
|
||||||
Username = user.UserName!,
|
Username = user.UserName!,
|
||||||
|
@ -319,11 +319,12 @@ public class SeriesController : BaseApiController
|
|||||||
/// <param name="libraryId"></param>
|
/// <param name="libraryId"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("all-v2")]
|
[HttpPost("all-v2")]
|
||||||
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
|
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams,
|
||||||
|
[FromQuery] int libraryId = 0, [FromQuery] QueryContext context = QueryContext.None)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
var series =
|
var series =
|
||||||
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto);
|
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context);
|
||||||
|
|
||||||
// Apply progress/rating information (I can't work out how to do this in initial query)
|
// Apply progress/rating information (I can't work out how to do this in initial query)
|
||||||
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
|
if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series"));
|
||||||
|
@ -277,4 +277,16 @@ public class ServerController : BaseApiController
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the Sync Themes task
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize("RequireAdminRole")]
|
||||||
|
[HttpPost("sync-themes")]
|
||||||
|
public async Task<ActionResult> SyncThemes()
|
||||||
|
{
|
||||||
|
await _taskScheduler.SyncThemes();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,7 @@ using Kavita.Common.Helpers;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
namespace API.Controllers;
|
namespace API.Controllers;
|
||||||
|
|
||||||
@ -370,7 +371,7 @@ public class SettingsController : BaseApiController
|
|||||||
return Ok(updateSettingsDto);
|
return Ok(updateSettingsDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
|
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
|
||||||
{
|
{
|
||||||
_directoryService.ExistOrCreate(bookmarkDirectory);
|
_directoryService.ExistOrCreate(bookmarkDirectory);
|
||||||
_directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory);
|
_directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory);
|
||||||
|
@ -53,4 +53,8 @@ public enum FilterComparison
|
|||||||
/// Is Date not between now and X seconds ago
|
/// Is Date not between now and X seconds ago
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IsNotInLast = 15,
|
IsNotInLast = 15,
|
||||||
|
/// <summary>
|
||||||
|
/// There are no records
|
||||||
|
/// </summary>
|
||||||
|
IsEmpty = 16
|
||||||
}
|
}
|
||||||
|
@ -159,7 +159,7 @@ public interface ISeriesRepository
|
|||||||
Task<int> GetAverageUserRating(int seriesId, int userId);
|
Task<int> GetAverageUserRating(int seriesId, int userId);
|
||||||
Task RemoveFromOnDeck(int seriesId, int userId);
|
Task RemoveFromOnDeck(int seriesId, int userId);
|
||||||
Task ClearOnDeckRemoval(int seriesId, int userId);
|
Task ClearOnDeckRemoval(int seriesId, int userId);
|
||||||
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto);
|
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None);
|
||||||
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
|
Task<PlusSeriesDto?> GetPlusSeriesDto(int seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -693,9 +693,9 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
return await query.ToListAsync();
|
return await query.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto)
|
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdV2Async(int userId, UserParams userParams, FilterV2Dto filterDto, QueryContext queryContext = QueryContext.None)
|
||||||
{
|
{
|
||||||
var query = await CreateFilteredSearchQueryableV2(userId, filterDto, QueryContext.None);
|
var query = await CreateFilteredSearchQueryableV2(userId, filterDto, queryContext);
|
||||||
|
|
||||||
var retSeries = query
|
var retSeries = query
|
||||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||||
@ -979,7 +979,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
|
.HasReleaseYear(hasReleaseYearMaxFilter, FilterComparison.LessThanEqual, filter.ReleaseYearRange?.Max)
|
||||||
.HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
|
.HasReleaseYear(hasReleaseYearMinFilter, FilterComparison.GreaterThanEqual, filter.ReleaseYearRange?.Min)
|
||||||
.HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
|
.HasName(hasSeriesNameFilter, FilterComparison.Matches, filter.SeriesNameQuery)
|
||||||
.HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating, userId)
|
.HasRating(hasRatingFilter, FilterComparison.GreaterThanEqual, filter.Rating / 100f, userId)
|
||||||
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
|
.HasAgeRating(hasAgeRating, FilterComparison.Contains, filter.AgeRating)
|
||||||
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
|
.HasPublicationStatus(hasPublicationFilter, FilterComparison.Contains, filter.PublicationStatus)
|
||||||
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
|
.HasTags(hasTagsFilter, FilterComparison.Contains, filter.Tags)
|
||||||
@ -987,7 +987,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
|
.HasGenre(hasGenresFilter, FilterComparison.Contains, filter.Genres)
|
||||||
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
|
.HasFormat(filter.Formats != null && filter.Formats.Count > 0, FilterComparison.Contains, filter.Formats!)
|
||||||
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
|
.HasAverageReadTime(true, FilterComparison.GreaterThanEqual, 0)
|
||||||
.HasPeople(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
|
.HasPeopleLegacy(hasPeopleFilter, FilterComparison.Contains, allPeopleIds)
|
||||||
|
|
||||||
.WhereIf(onlyParentSeries,
|
.WhereIf(onlyParentSeries,
|
||||||
s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
|
s => s.RelationOf.Count == 0 || s.RelationOf.All(p => p.RelationKind == RelationKind.Prequel))
|
||||||
@ -1215,6 +1215,7 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
|
|
||||||
private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> query)
|
private static IQueryable<Series> BuildFilterGroup(int userId, FilterStatementDto statement, IQueryable<Series> query)
|
||||||
{
|
{
|
||||||
|
|
||||||
var value = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
|
var value = FilterFieldValueConverter.ConvertValue(statement.Field, statement.Value);
|
||||||
return statement.Field switch
|
return statement.Field switch
|
||||||
{
|
{
|
||||||
@ -1226,21 +1227,21 @@ public class SeriesRepository : ISeriesRepository
|
|||||||
(IList<PublicationStatus>) value),
|
(IList<PublicationStatus>) value),
|
||||||
FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
|
FilterField.Languages => query.HasLanguage(true, statement.Comparison, (IList<string>) value),
|
||||||
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
|
FilterField.AgeRating => query.HasAgeRating(true, statement.Comparison, (IList<AgeRating>) value),
|
||||||
FilterField.UserRating => query.HasRating(true, statement.Comparison, (int) value, userId),
|
FilterField.UserRating => query.HasRating(true, statement.Comparison, (float) value , userId),
|
||||||
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
|
FilterField.Tags => query.HasTags(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Translators => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Translator),
|
||||||
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Characters => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Character),
|
||||||
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Publisher => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Publisher),
|
||||||
FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Editor => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Editor),
|
||||||
FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.CoverArtist => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.CoverArtist),
|
||||||
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Letterer),
|
||||||
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Inker),
|
||||||
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Inker),
|
||||||
FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Imprint),
|
||||||
FilterField.Team => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Team => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Team),
|
||||||
FilterField.Location => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Location => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Location),
|
||||||
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Penciller),
|
||||||
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
|
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value, PersonRole.Writer),
|
||||||
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
|
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
|
||||||
FilterField.CollectionTags =>
|
FilterField.CollectionTags =>
|
||||||
// This is handled in the code before this as it's handled in a more general, combined manner
|
// This is handled in the code before this as it's handled in a more general, combined manner
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
|
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace API.Entities;
|
namespace API.Entities;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
public class AppUserRating
|
public class AppUserRating
|
||||||
@ -9,7 +11,7 @@ public class AppUserRating
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public float Rating { get; set; }
|
public float Rating { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If the rating has been explicitly set. Otherwise the 0.0 rating should be ignored as it's not rated
|
/// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasBeenRated { get; set; }
|
public bool HasBeenRated { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -19,6 +21,7 @@ public class AppUserRating
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// An optional tagline for the review
|
/// An optional tagline for the review
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Obsolete("No longer used")]
|
||||||
public string? Tagline { get; set; }
|
public string? Tagline { get; set; }
|
||||||
public int SeriesId { get; set; }
|
public int SeriesId { get; set; }
|
||||||
public Series Series { get; set; } = null!;
|
public Series Series { get; set; } = null!;
|
||||||
|
@ -43,6 +43,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsAfter:
|
case FilterComparison.IsAfter:
|
||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
}
|
}
|
||||||
@ -71,6 +72,8 @@ public static class SeriesFilter
|
|||||||
return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear);
|
return queryable.Where(s => s.Metadata.ReleaseYear >= DateTime.Now.Year - (int) releaseYear);
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear);
|
return queryable.Where(s => s.Metadata.ReleaseYear < DateTime.Now.Year - (int) releaseYear);
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return queryable.Where(s => s.Metadata.ReleaseYear == 0);
|
||||||
case FilterComparison.Matches:
|
case FilterComparison.Matches:
|
||||||
case FilterComparison.Contains:
|
case FilterComparison.Contains:
|
||||||
case FilterComparison.NotContains:
|
case FilterComparison.NotContains:
|
||||||
@ -86,14 +89,20 @@ public static class SeriesFilter
|
|||||||
|
|
||||||
|
|
||||||
public static IQueryable<Series> HasRating(this IQueryable<Series> queryable, bool condition,
|
public static IQueryable<Series> HasRating(this IQueryable<Series> queryable, bool condition,
|
||||||
FilterComparison comparison, int rating, int userId)
|
FilterComparison comparison, float rating, int userId)
|
||||||
{
|
{
|
||||||
if (rating < 0 || !condition || userId <= 0) return queryable;
|
if (rating < 0 || !condition || userId <= 0) return queryable;
|
||||||
|
|
||||||
|
// Users see rating as %, so they are likely to pass 10%. We need to turn that into the underlying float encoding
|
||||||
|
if (rating.IsNot(0f))
|
||||||
|
{
|
||||||
|
rating /= 100f;
|
||||||
|
}
|
||||||
|
|
||||||
switch (comparison)
|
switch (comparison)
|
||||||
{
|
{
|
||||||
case FilterComparison.Equal:
|
case FilterComparison.Equal:
|
||||||
return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) < FloatingPointTolerance && r.AppUserId == userId));
|
return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) <= FloatingPointTolerance && r.AppUserId == userId));
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
|
return queryable.Where(s => s.Ratings.Any(r => r.Rating > rating && r.AppUserId == userId));
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
@ -102,10 +111,13 @@ public static class SeriesFilter
|
|||||||
return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId));
|
return queryable.Where(s => s.Ratings.Any(r => r.Rating < rating && r.AppUserId == userId));
|
||||||
case FilterComparison.LessThanEqual:
|
case FilterComparison.LessThanEqual:
|
||||||
return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId));
|
return queryable.Where(s => s.Ratings.Any(r => r.Rating <= rating && r.AppUserId == userId));
|
||||||
|
case FilterComparison.NotEqual:
|
||||||
|
return queryable.Where(s => s.Ratings.Any(r => Math.Abs(r.Rating - rating) >= FloatingPointTolerance && r.AppUserId == userId));
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return queryable.Where(s => s.Ratings.All(r => r.AppUserId != userId));
|
||||||
case FilterComparison.Contains:
|
case FilterComparison.Contains:
|
||||||
case FilterComparison.Matches:
|
case FilterComparison.Matches:
|
||||||
case FilterComparison.NotContains:
|
case FilterComparison.NotContains:
|
||||||
case FilterComparison.NotEqual:
|
|
||||||
case FilterComparison.BeginsWith:
|
case FilterComparison.BeginsWith:
|
||||||
case FilterComparison.EndsWith:
|
case FilterComparison.EndsWith:
|
||||||
case FilterComparison.IsBefore:
|
case FilterComparison.IsBefore:
|
||||||
@ -124,7 +136,7 @@ public static class SeriesFilter
|
|||||||
{
|
{
|
||||||
if (!condition || ratings.Count == 0) return queryable;
|
if (!condition || ratings.Count == 0) return queryable;
|
||||||
|
|
||||||
var firstRating = ratings.First();
|
var firstRating = ratings[0];
|
||||||
switch (comparison)
|
switch (comparison)
|
||||||
{
|
{
|
||||||
case FilterComparison.Equal:
|
case FilterComparison.Equal:
|
||||||
@ -151,6 +163,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.AgeRating");
|
throw new KavitaException($"{comparison} not applicable for Series.AgeRating");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
@ -185,6 +198,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime");
|
throw new KavitaException($"{comparison} not applicable for Series.AverageReadTime");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
@ -196,7 +210,7 @@ public static class SeriesFilter
|
|||||||
{
|
{
|
||||||
if (!condition || pubStatues.Count == 0) return queryable;
|
if (!condition || pubStatues.Count == 0) return queryable;
|
||||||
|
|
||||||
var firstStatus = pubStatues.First();
|
var firstStatus = pubStatues[0];
|
||||||
switch (comparison)
|
switch (comparison)
|
||||||
{
|
{
|
||||||
case FilterComparison.Equal:
|
case FilterComparison.Equal:
|
||||||
@ -219,6 +233,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.Matches:
|
case FilterComparison.Matches:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus");
|
throw new KavitaException($"{comparison} not applicable for Series.PublicationStatus");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
@ -269,6 +284,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.NotEqual:
|
case FilterComparison.NotEqual:
|
||||||
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance);
|
subQuery = subQuery.Where(s => Math.Abs(s.Percentage - readProgress) > FloatingPointTolerance);
|
||||||
break;
|
break;
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
case FilterComparison.Matches:
|
case FilterComparison.Matches:
|
||||||
case FilterComparison.Contains:
|
case FilterComparison.Contains:
|
||||||
case FilterComparison.NotContains:
|
case FilterComparison.NotContains:
|
||||||
@ -293,6 +309,7 @@ public static class SeriesFilter
|
|||||||
{
|
{
|
||||||
if (!condition) return queryable;
|
if (!condition) return queryable;
|
||||||
|
|
||||||
|
|
||||||
var subQuery = queryable
|
var subQuery = queryable
|
||||||
.Where(s => s.ExternalSeriesMetadata != null)
|
.Where(s => s.ExternalSeriesMetadata != null)
|
||||||
.Include(s => s.ExternalSeriesMetadata)
|
.Include(s => s.ExternalSeriesMetadata)
|
||||||
@ -334,6 +351,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.AverageRating");
|
throw new KavitaException($"{comparison} not applicable for Series.AverageRating");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
@ -393,6 +411,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
|
throw new KavitaException($"{comparison} not applicable for Series.ReadProgress");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
@ -424,6 +443,8 @@ public static class SeriesFilter
|
|||||||
queries.AddRange(tags.Select(gId => queryable.Where(s => s.Metadata.Tags.Any(p => p.Id == gId))));
|
queries.AddRange(tags.Select(gId => queryable.Where(s => s.Metadata.Tags.Any(p => p.Id == gId))));
|
||||||
|
|
||||||
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return queryable.Where(s => s.Metadata.Tags == null || s.Metadata.Tags.Count == 0);
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
case FilterComparison.LessThan:
|
case FilterComparison.LessThan:
|
||||||
@ -442,6 +463,48 @@ public static class SeriesFilter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static IQueryable<Series> HasPeople(this IQueryable<Series> queryable, bool condition,
|
public static IQueryable<Series> HasPeople(this IQueryable<Series> queryable, bool condition,
|
||||||
|
FilterComparison comparison, IList<int> people, PersonRole role)
|
||||||
|
{
|
||||||
|
if (!condition || (comparison != FilterComparison.IsEmpty && people.Count == 0)) return queryable;
|
||||||
|
|
||||||
|
switch (comparison)
|
||||||
|
{
|
||||||
|
case FilterComparison.Equal:
|
||||||
|
case FilterComparison.Contains:
|
||||||
|
return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id)));
|
||||||
|
case FilterComparison.NotEqual:
|
||||||
|
case FilterComparison.NotContains:
|
||||||
|
return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.Id)));
|
||||||
|
case FilterComparison.MustContains:
|
||||||
|
// Deconstruct and do a Union of a bunch of where statements since this doesn't translate
|
||||||
|
var queries = new List<IQueryable<Series>>()
|
||||||
|
{
|
||||||
|
queryable
|
||||||
|
};
|
||||||
|
queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId))));
|
||||||
|
|
||||||
|
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
// Check if there are no people with specific roles (e.g., Writer, Penciller, etc.)
|
||||||
|
return queryable.Where(s => !s.Metadata.People.Any(p => p.Role == role));
|
||||||
|
case FilterComparison.GreaterThan:
|
||||||
|
case FilterComparison.GreaterThanEqual:
|
||||||
|
case FilterComparison.LessThan:
|
||||||
|
case FilterComparison.LessThanEqual:
|
||||||
|
case FilterComparison.BeginsWith:
|
||||||
|
case FilterComparison.EndsWith:
|
||||||
|
case FilterComparison.IsBefore:
|
||||||
|
case FilterComparison.IsAfter:
|
||||||
|
case FilterComparison.IsInLast:
|
||||||
|
case FilterComparison.IsNotInLast:
|
||||||
|
case FilterComparison.Matches:
|
||||||
|
throw new KavitaException($"{comparison} not applicable for Series.People");
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<Series> HasPeopleLegacy(this IQueryable<Series> queryable, bool condition,
|
||||||
FilterComparison comparison, IList<int> people)
|
FilterComparison comparison, IList<int> people)
|
||||||
{
|
{
|
||||||
if (!condition || people.Count == 0) return queryable;
|
if (!condition || people.Count == 0) return queryable;
|
||||||
@ -463,6 +526,7 @@ public static class SeriesFilter
|
|||||||
queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId))));
|
queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId))));
|
||||||
|
|
||||||
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
case FilterComparison.LessThan:
|
case FilterComparison.LessThan:
|
||||||
@ -502,6 +566,8 @@ public static class SeriesFilter
|
|||||||
queries.AddRange(genres.Select(gId => queryable.Where(s => s.Metadata.Genres.Any(p => p.Id == gId))));
|
queries.AddRange(genres.Select(gId => queryable.Where(s => s.Metadata.Genres.Any(p => p.Id == gId))));
|
||||||
|
|
||||||
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return queryable.Where(s => s.Metadata.Genres.Count == 0);
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
case FilterComparison.LessThan:
|
case FilterComparison.LessThan:
|
||||||
@ -544,6 +610,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsAfter:
|
case FilterComparison.IsAfter:
|
||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.Format");
|
throw new KavitaException($"{comparison} not applicable for Series.Format");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, null);
|
||||||
@ -573,6 +640,8 @@ public static class SeriesFilter
|
|||||||
queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id))));
|
queries.AddRange(collectionSeries.Select(gId => queryable.Where(s => collectionSeries.Any(p => p == s.Id))));
|
||||||
|
|
||||||
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
return queries.Aggregate((q1, q2) => q1.Intersect(q2));
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return queryable.Where(s => collectionSeries.All(c => c != s.Id));
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
case FilterComparison.LessThan:
|
case FilterComparison.LessThan:
|
||||||
@ -633,6 +702,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.Name");
|
throw new KavitaException($"{comparison} not applicable for Series.Name");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
|
||||||
@ -656,6 +726,8 @@ public static class SeriesFilter
|
|||||||
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%"));
|
return queryable.Where(s => EF.Functions.Like(s.Metadata.Summary, $"%{queryString}%"));
|
||||||
case FilterComparison.NotEqual:
|
case FilterComparison.NotEqual:
|
||||||
return queryable.Where(s => s.Metadata.Summary != queryString);
|
return queryable.Where(s => s.Metadata.Summary != queryString);
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return queryable.Where(s => string.IsNullOrEmpty(s.Metadata.Summary));
|
||||||
case FilterComparison.NotContains:
|
case FilterComparison.NotContains:
|
||||||
case FilterComparison.GreaterThan:
|
case FilterComparison.GreaterThan:
|
||||||
case FilterComparison.GreaterThanEqual:
|
case FilterComparison.GreaterThanEqual:
|
||||||
@ -703,6 +775,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
|
throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
|
||||||
@ -779,6 +852,7 @@ public static class SeriesFilter
|
|||||||
case FilterComparison.IsInLast:
|
case FilterComparison.IsInLast:
|
||||||
case FilterComparison.IsNotInLast:
|
case FilterComparison.IsNotInLast:
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
|
throw new KavitaException($"{comparison} not applicable for Series.FolderPath");
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
|
throw new ArgumentOutOfRangeException(nameof(comparison), comparison, "Filter Comparison is not supported");
|
||||||
|
@ -18,75 +18,94 @@ public static class FilterFieldValueConverter
|
|||||||
FilterField.SeriesName => value,
|
FilterField.SeriesName => value,
|
||||||
FilterField.Path => value,
|
FilterField.Path => value,
|
||||||
FilterField.FilePath => value,
|
FilterField.FilePath => value,
|
||||||
FilterField.ReleaseYear => int.Parse(value),
|
FilterField.ReleaseYear => string.IsNullOrEmpty(value) ? 0 : int.Parse(value),
|
||||||
FilterField.Languages => value.Split(',').ToList(),
|
FilterField.Languages => value.Split(',').ToList(),
|
||||||
FilterField.PublicationStatus => value.Split(',')
|
FilterField.PublicationStatus => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x))
|
.Select(x => (PublicationStatus) Enum.Parse(typeof(PublicationStatus), x))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Summary => value,
|
FilterField.Summary => value,
|
||||||
FilterField.AgeRating => value.Split(',')
|
FilterField.AgeRating => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x))
|
.Select(x => (AgeRating) Enum.Parse(typeof(AgeRating), x))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.UserRating => int.Parse(value),
|
FilterField.UserRating => string.IsNullOrEmpty(value) ? 0 : float.Parse(value),
|
||||||
FilterField.Tags => value.Split(',')
|
FilterField.Tags => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.CollectionTags => value.Split(',')
|
FilterField.CollectionTags => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Translators => value.Split(',')
|
FilterField.Translators => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Characters => value.Split(',')
|
FilterField.Characters => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Publisher => value.Split(',')
|
FilterField.Publisher => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Editor => value.Split(',')
|
FilterField.Editor => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.CoverArtist => value.Split(',')
|
FilterField.CoverArtist => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Letterer => value.Split(',')
|
FilterField.Letterer => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Colorist => value.Split(',')
|
FilterField.Colorist => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Inker => value.Split(',')
|
FilterField.Inker => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Imprint => value.Split(',')
|
FilterField.Imprint => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Team => value.Split(',')
|
FilterField.Team => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Location => value.Split(',')
|
FilterField.Location => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Penciller => value.Split(',')
|
FilterField.Penciller => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Writers => value.Split(',')
|
FilterField.Writers => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Genres => value.Split(',')
|
FilterField.Genres => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.Libraries => value.Split(',')
|
FilterField.Libraries => value.Split(',')
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s))
|
||||||
.Select(int.Parse)
|
.Select(int.Parse)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.WantToRead => bool.Parse(value),
|
FilterField.WantToRead => bool.Parse(value),
|
||||||
FilterField.ReadProgress => value.AsFloat(),
|
FilterField.ReadProgress => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(),
|
||||||
FilterField.ReadingDate => DateTime.Parse(value),
|
FilterField.ReadingDate => DateTime.Parse(value),
|
||||||
FilterField.Formats => value.Split(',')
|
FilterField.Formats => value.Split(',')
|
||||||
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
.Select(x => (MangaFormat) Enum.Parse(typeof(MangaFormat), x))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
FilterField.ReadTime => int.Parse(value),
|
FilterField.ReadTime => string.IsNullOrEmpty(value) ? 0 : int.Parse(value),
|
||||||
FilterField.AverageRating => value.AsFloat(),
|
FilterField.AverageRating => string.IsNullOrEmpty(value) ? 0f : value.AsFloat(),
|
||||||
_ => throw new ArgumentException("Invalid field type")
|
_ => throw new ArgumentException("Invalid field type")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -94,7 +94,7 @@ public class DirectoryService : IDirectoryService
|
|||||||
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
||||||
|
|
||||||
private static readonly Regex ExcludeDirectories = new Regex(
|
private static readonly Regex ExcludeDirectories = new Regex(
|
||||||
@"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle",
|
@"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle|\.yacreaderlibrary",
|
||||||
MatchOptions,
|
MatchOptions,
|
||||||
Tasks.Scanner.Parser.Parser.RegexTimeout);
|
Tasks.Scanner.Parser.Parser.RegexTimeout);
|
||||||
private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)",
|
private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)",
|
||||||
|
@ -69,7 +69,8 @@ public class StatisticService : IStatisticService
|
|||||||
var totalPagesRead = await _context.AppUserProgresses
|
var totalPagesRead = await _context.AppUserProgresses
|
||||||
.Where(p => p.AppUserId == userId)
|
.Where(p => p.AppUserId == userId)
|
||||||
.Where(p => libraryIds.Contains(p.LibraryId))
|
.Where(p => libraryIds.Contains(p.LibraryId))
|
||||||
.SumAsync(p => p.PagesRead);
|
.Select(p => (int?) p.PagesRead)
|
||||||
|
.SumAsync() ?? 0;
|
||||||
|
|
||||||
var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds);
|
var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds);
|
||||||
|
|
||||||
|
@ -37,7 +37,7 @@ public interface ITaskScheduler
|
|||||||
void CovertAllCoversToEncoding();
|
void CovertAllCoversToEncoding();
|
||||||
Task CleanupDbEntries();
|
Task CleanupDbEntries();
|
||||||
Task CheckForUpdate();
|
Task CheckForUpdate();
|
||||||
|
Task SyncThemes();
|
||||||
}
|
}
|
||||||
public class TaskScheduler : ITaskScheduler
|
public class TaskScheduler : ITaskScheduler
|
||||||
{
|
{
|
||||||
@ -165,8 +165,8 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(),
|
RecurringJob.AddOrUpdate(UpdateYearlyStatsTaskId, () => _statisticService.UpdateServerStatistics(),
|
||||||
Cron.Monthly, RecurringJobOptions);
|
Cron.Monthly, RecurringJobOptions);
|
||||||
|
|
||||||
RecurringJob.AddOrUpdate(SyncThemesTaskId, () => _themeService.SyncThemes(),
|
RecurringJob.AddOrUpdate(SyncThemesTaskId, () => SyncThemes(),
|
||||||
Cron.Weekly, RecurringJobOptions);
|
Cron.Daily, RecurringJobOptions);
|
||||||
|
|
||||||
await ScheduleKavitaPlusTasks();
|
await ScheduleKavitaPlusTasks();
|
||||||
}
|
}
|
||||||
@ -444,6 +444,11 @@ public class TaskScheduler : ITaskScheduler
|
|||||||
await _versionUpdaterService.PushUpdate(update);
|
await _versionUpdaterService.PushUpdate(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SyncThemes()
|
||||||
|
{
|
||||||
|
await _themeService.SyncThemes();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// If there is an enqueued or scheduled task for <see cref="ScannerService.ScanLibrary"/> method
|
/// If there is an enqueued or scheduled task for <see cref="ScannerService.ScanLibrary"/> method
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -1152,6 +1152,7 @@ public static class Parser
|
|||||||
return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle")
|
return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("@recycle")
|
||||||
|| path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg")
|
|| path.StartsWith("._") || Path.GetFileName(path).StartsWith("._") || path.Contains(".qpkg")
|
||||||
|| path.StartsWith("#recycle")
|
|| path.StartsWith("#recycle")
|
||||||
|
|| path.Contains(".yacreaderlibrary")
|
||||||
|| path.Contains(".caltrash");
|
|| path.Contains(".caltrash");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,7 +120,11 @@ public class ThemeService : IThemeService
|
|||||||
public async Task<List<DownloadableSiteThemeDto>> GetDownloadableThemes()
|
public async Task<List<DownloadableSiteThemeDto>> GetDownloadableThemes()
|
||||||
{
|
{
|
||||||
const string cacheKey = "browse";
|
const string cacheKey = "browse";
|
||||||
var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).ToDictionary(k => k.Name);
|
// Avoid a duplicate Dark issue some users faced during migration
|
||||||
|
var existingThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos())
|
||||||
|
.GroupBy(k => k.Name)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
if (_cache.TryGetValue(cacheKey, out List<DownloadableSiteThemeDto>? themes) && themes != null)
|
if (_cache.TryGetValue(cacheKey, out List<DownloadableSiteThemeDto>? themes) && themes != null)
|
||||||
{
|
{
|
||||||
foreach (var t in themes)
|
foreach (var t in themes)
|
||||||
@ -204,6 +208,13 @@ public class ThemeService : IThemeService
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
private async Task<IDictionary<string, ThemeMetadata>> GetReadme()
|
private async Task<IDictionary<string, ThemeMetadata>> GetReadme()
|
||||||
{
|
{
|
||||||
|
// Try and delete a Readme file if it already exists
|
||||||
|
var existingReadmeFile = _directoryService.FileSystem.Path.Join(_directoryService.TempDirectory, "README.md");
|
||||||
|
if (_directoryService.FileSystem.File.Exists(existingReadmeFile))
|
||||||
|
{
|
||||||
|
_directoryService.DeleteFiles([existingReadmeFile]);
|
||||||
|
}
|
||||||
|
|
||||||
var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory);
|
var tempDownloadFile = await GithubReadme.DownloadFileAsync(_directoryService.TempDirectory);
|
||||||
|
|
||||||
// Read file into Markdown
|
// Read file into Markdown
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"TokenKey": "super secret unguessable key that is longer because we require it",
|
"TokenKey": "super secret unguessable key that is longer because we require it",
|
||||||
"Port": 5000,
|
"Port": 5000,
|
||||||
"IpAddresses": "0.0.0.0,::",
|
"IpAddresses": "0.0.0.0,::",
|
||||||
"BaseUrl": "/",
|
"BaseUrl": "/test/",
|
||||||
"Cache": 75,
|
"Cache": 75,
|
||||||
"AllowIFraming": false
|
"AllowIFraming": false
|
||||||
}
|
}
|
@ -149,9 +149,6 @@ $image-width: 160px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 115;
|
z-index: 115;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,8 +158,41 @@ $image-width: 160px;
|
|||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-title-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 120px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep app-card-actionables .dropdown .dropdown-toggle {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 120px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body > div:nth-child(2) {
|
.card-body > div:nth-child(2) {
|
||||||
|
@ -43,4 +43,5 @@ export enum FilterComparison {
|
|||||||
/// Is Date not between now and X seconds ago
|
/// Is Date not between now and X seconds ago
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IsNotInLast = 15,
|
IsNotInLast = 15,
|
||||||
|
IsEmpty = 16
|
||||||
}
|
}
|
||||||
|
7
UI/Web/src/app/_models/metadata/v2/query-context.ts
Normal file
7
UI/Web/src/app/_models/metadata/v2/query-context.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export enum QueryContext
|
||||||
|
{
|
||||||
|
None = 1,
|
||||||
|
Search = 2,
|
||||||
|
Recommended = 3,
|
||||||
|
Dashboard = 4,
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
|
import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'bookPageLayoutMode',
|
name: 'bookPageLayoutMode',
|
||||||
@ -9,7 +10,8 @@ import {BookPageLayoutMode} from "../_models/readers/book-page-layout-mode";
|
|||||||
export class BookPageLayoutModePipe implements PipeTransform {
|
export class BookPageLayoutModePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: BookPageLayoutMode): string {
|
transform(value: BookPageLayoutMode): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as BookPageLayoutMode;
|
||||||
|
switch (v) {
|
||||||
case BookPageLayoutMode.Column1: return translate('preferences.1-column');
|
case BookPageLayoutMode.Column1: return translate('preferences.1-column');
|
||||||
case BookPageLayoutMode.Column2: return translate('preferences.2-column');
|
case BookPageLayoutMode.Column2: return translate('preferences.2-column');
|
||||||
case BookPageLayoutMode.Default: return translate('preferences.scroll');
|
case BookPageLayoutMode.Default: return translate('preferences.scroll');
|
||||||
|
@ -42,6 +42,8 @@ export class FilterComparisonPipe implements PipeTransform {
|
|||||||
return translate('filter-comparison-pipe.is-not-in-last');
|
return translate('filter-comparison-pipe.is-not-in-last');
|
||||||
case FilterComparison.MustContains:
|
case FilterComparison.MustContains:
|
||||||
return translate('filter-comparison-pipe.must-contains');
|
return translate('filter-comparison-pipe.must-contains');
|
||||||
|
case FilterComparison.IsEmpty:
|
||||||
|
return translate('filter-comparison-pipe.is-empty');
|
||||||
default:
|
default:
|
||||||
throw new Error(`Invalid FilterComparison value: ${value}`);
|
throw new Error(`Invalid FilterComparison value: ${value}`);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {LayoutMode} from "../manga-reader/_models/layout-mode";
|
import {LayoutMode} from "../manga-reader/_models/layout-mode";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'layoutMode',
|
name: 'layoutMode',
|
||||||
@ -9,7 +10,8 @@ import {LayoutMode} from "../manga-reader/_models/layout-mode";
|
|||||||
export class LayoutModePipe implements PipeTransform {
|
export class LayoutModePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: LayoutMode): string {
|
transform(value: LayoutMode): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as LayoutMode;
|
||||||
|
switch (v) {
|
||||||
case LayoutMode.Single: return translate('preferences.single');
|
case LayoutMode.Single: return translate('preferences.single');
|
||||||
case LayoutMode.Double: return translate('preferences.double');
|
case LayoutMode.Double: return translate('preferences.double');
|
||||||
case LayoutMode.DoubleReversed: return translate('preferences.double-manga');
|
case LayoutMode.DoubleReversed: return translate('preferences.double-manga');
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {PageSplitOption} from "../_models/preferences/page-split-option";
|
import {PageSplitOption} from "../_models/preferences/page-split-option";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'pageSplitOption',
|
name: 'pageSplitOption',
|
||||||
@ -9,7 +10,8 @@ import {PageSplitOption} from "../_models/preferences/page-split-option";
|
|||||||
export class PageSplitOptionPipe implements PipeTransform {
|
export class PageSplitOptionPipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: PageSplitOption): string {
|
transform(value: PageSplitOption): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as PageSplitOption;
|
||||||
|
switch (v) {
|
||||||
case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen');
|
case PageSplitOption.FitSplit: return translate('preferences.fit-to-screen');
|
||||||
case PageSplitOption.NoSplit: return translate('preferences.no-split');
|
case PageSplitOption.NoSplit: return translate('preferences.no-split');
|
||||||
case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right');
|
case PageSplitOption.SplitLeftToRight: return translate('preferences.split-left-to-right');
|
||||||
|
@ -9,7 +9,8 @@ import {PdfScrollMode} from "../_models/preferences/pdf-scroll-mode";
|
|||||||
export class PdfScrollModePipe implements PipeTransform {
|
export class PdfScrollModePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: PdfScrollMode): string {
|
transform(value: PdfScrollMode): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as PdfScrollMode;
|
||||||
|
switch (v) {
|
||||||
case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple');
|
case PdfScrollMode.Wrapped: return translate('preferences.pdf-multiple');
|
||||||
case PdfScrollMode.Page: return translate('preferences.pdf-page');
|
case PdfScrollMode.Page: return translate('preferences.pdf-page');
|
||||||
case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal');
|
case PdfScrollMode.Horizontal: return translate('preferences.pdf-horizontal');
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode";
|
import {PdfSpreadMode} from "../_models/preferences/pdf-spread-mode";
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'pdfSpreadMode',
|
name: 'pdfSpreadMode',
|
||||||
@ -9,7 +10,8 @@ import {translate} from "@jsverse/transloco";
|
|||||||
export class PdfSpreadModePipe implements PipeTransform {
|
export class PdfSpreadModePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: PdfSpreadMode): string {
|
transform(value: PdfSpreadMode): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as PdfSpreadMode;
|
||||||
|
switch (v) {
|
||||||
case PdfSpreadMode.None: return translate('preferences.pdf-none');
|
case PdfSpreadMode.None: return translate('preferences.pdf-none');
|
||||||
case PdfSpreadMode.Odd: return translate('preferences.pdf-odd');
|
case PdfSpreadMode.Odd: return translate('preferences.pdf-odd');
|
||||||
case PdfSpreadMode.Even: return translate('preferences.pdf-even');
|
case PdfSpreadMode.Even: return translate('preferences.pdf-even');
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {PdfTheme} from "../_models/preferences/pdf-theme";
|
import {PdfTheme} from "../_models/preferences/pdf-theme";
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'pdfTheme',
|
name: 'pdfTheme',
|
||||||
@ -9,7 +10,8 @@ import {translate} from "@jsverse/transloco";
|
|||||||
export class PdfThemePipe implements PipeTransform {
|
export class PdfThemePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: PdfTheme): string {
|
transform(value: PdfTheme): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as PdfTheme;
|
||||||
|
switch (v) {
|
||||||
case PdfTheme.Dark: return translate('preferences.pdf-dark');
|
case PdfTheme.Dark: return translate('preferences.pdf-dark');
|
||||||
case PdfTheme.Light: return translate('preferences.pdf-light');
|
case PdfTheme.Light: return translate('preferences.pdf-light');
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,8 @@ import {translate} from "@jsverse/transloco";
|
|||||||
export class ReadingDirectionPipe implements PipeTransform {
|
export class ReadingDirectionPipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: ReadingDirection): string {
|
transform(value: ReadingDirection): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as ReadingDirection;
|
||||||
|
switch (v) {
|
||||||
case ReadingDirection.LeftToRight: return translate('preferences.left-to-right');
|
case ReadingDirection.LeftToRight: return translate('preferences.left-to-right');
|
||||||
case ReadingDirection.RightToLeft: return translate('preferences.right-to-left');
|
case ReadingDirection.RightToLeft: return translate('preferences.right-to-left');
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {ReaderMode} from "../_models/preferences/reader-mode";
|
import {ReaderMode} from "../_models/preferences/reader-mode";
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'readerMode',
|
name: 'readerMode',
|
||||||
@ -9,7 +10,8 @@ import {translate} from "@jsverse/transloco";
|
|||||||
export class ReaderModePipe implements PipeTransform {
|
export class ReaderModePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: ReaderMode): string {
|
transform(value: ReaderMode): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as ReaderMode;
|
||||||
|
switch (v) {
|
||||||
case ReaderMode.UpDown: return translate('preferences.up-to-down');
|
case ReaderMode.UpDown: return translate('preferences.up-to-down');
|
||||||
case ReaderMode.Webtoon: return translate('preferences.webtoon');
|
case ReaderMode.Webtoon: return translate('preferences.webtoon');
|
||||||
case ReaderMode.LeftRight: return translate('preferences.left-to-right');
|
case ReaderMode.LeftRight: return translate('preferences.left-to-right');
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {ScalingOption} from "../_models/preferences/scaling-option";
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
import {ReadingDirection} from "../_models/preferences/reading-direction";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'scalingOption',
|
name: 'scalingOption',
|
||||||
@ -9,7 +10,8 @@ import {ScalingOption} from "../_models/preferences/scaling-option";
|
|||||||
export class ScalingOptionPipe implements PipeTransform {
|
export class ScalingOptionPipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: ScalingOption): string {
|
transform(value: ScalingOption): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as ScalingOption;
|
||||||
|
switch (v) {
|
||||||
case ScalingOption.Automatic: return translate('preferences.automatic');
|
case ScalingOption.Automatic: return translate('preferences.automatic');
|
||||||
case ScalingOption.FitToHeight: return translate('preferences.fit-to-height');
|
case ScalingOption.FitToHeight: return translate('preferences.fit-to-height');
|
||||||
case ScalingOption.FitToWidth: return translate('preferences.fit-to-width');
|
case ScalingOption.FitToWidth: return translate('preferences.fit-to-width');
|
||||||
|
@ -16,7 +16,10 @@ type UtcToLocalTimeFormat = 'full' | 'short' | 'shortDate' | 'shortTime';
|
|||||||
export class UtcToLocalTimePipe implements PipeTransform {
|
export class UtcToLocalTimePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(utcDate: string | undefined | null, format: UtcToLocalTimeFormat = 'short'): string {
|
transform(utcDate: string | undefined | null, format: UtcToLocalTimeFormat = 'short'): string {
|
||||||
if (utcDate === undefined || utcDate === null) return '';
|
if (utcDate === '' || utcDate === null || utcDate === undefined || utcDate.split('T')[0] === '0001-01-01') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
const browserLanguage = navigator.language;
|
const browserLanguage = navigator.language;
|
||||||
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage);
|
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage);
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core';
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
import {translate} from "@jsverse/transloco";
|
import {translate} from "@jsverse/transloco";
|
||||||
import {WritingStyle} from "../_models/preferences/writing-style";
|
import {WritingStyle} from "../_models/preferences/writing-style";
|
||||||
|
import {ScalingOption} from "../_models/preferences/scaling-option";
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'writingStyle',
|
name: 'writingStyle',
|
||||||
@ -9,7 +10,8 @@ import {WritingStyle} from "../_models/preferences/writing-style";
|
|||||||
export class WritingStylePipe implements PipeTransform {
|
export class WritingStylePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: WritingStyle): string {
|
transform(value: WritingStyle): string {
|
||||||
switch (value) {
|
const v = parseInt(value + '', 10) as WritingStyle;
|
||||||
|
switch (v) {
|
||||||
case WritingStyle.Horizontal: return translate('preferences.horizontal');
|
case WritingStyle.Horizontal: return translate('preferences.horizontal');
|
||||||
case WritingStyle.Vertical: return translate('preferences.vertical');
|
case WritingStyle.Vertical: return translate('preferences.vertical');
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import { AgeRating } from '../_models/metadata/age-rating';
|
|||||||
import { AgeRestriction } from '../_models/metadata/age-restriction';
|
import { AgeRestriction } from '../_models/metadata/age-restriction';
|
||||||
import { TextResonse } from '../_types/text-response';
|
import { TextResonse } from '../_types/text-response';
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
import {Action} from "./action-factory.service";
|
||||||
|
|
||||||
export enum Role {
|
export enum Role {
|
||||||
Admin = 'Admin',
|
Admin = 'Admin',
|
||||||
@ -78,6 +79,18 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canInvokeAction(user: User, action: Action) {
|
||||||
|
const isAdmin = this.hasAdminRole(user);
|
||||||
|
const canDownload = this.hasDownloadRole(user);
|
||||||
|
const canPromote = this.hasPromoteRole(user);
|
||||||
|
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (action === Action.Download) return canDownload;
|
||||||
|
if (action === Action.Promote || action === Action.UnPromote) return canPromote;
|
||||||
|
if (action === Action.Delete) return isAdmin;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
hasAnyRole(user: User, roles: Array<Role>) {
|
hasAnyRole(user: User, roles: Array<Role>) {
|
||||||
if (!user || !user.roles) {
|
if (!user || !user.roles) {
|
||||||
return false;
|
return false;
|
||||||
@ -168,7 +181,7 @@ export class AccountService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentUser(user?: User) {
|
setCurrentUser(user?: User, refreshConnections = true) {
|
||||||
if (user) {
|
if (user) {
|
||||||
user.roles = [];
|
user.roles = [];
|
||||||
const roles = this.getDecodedToken(user.token).role;
|
const roles = this.getDecodedToken(user.token).role;
|
||||||
@ -189,6 +202,8 @@ export class AccountService {
|
|||||||
this.currentUser = user;
|
this.currentUser = user;
|
||||||
this.currentUserSource.next(user);
|
this.currentUserSource.next(user);
|
||||||
|
|
||||||
|
if (!refreshConnections) return;
|
||||||
|
|
||||||
this.stopRefreshTokenTimer();
|
this.stopRefreshTokenTimer();
|
||||||
|
|
||||||
if (this.currentUser) {
|
if (this.currentUser) {
|
||||||
@ -311,7 +326,7 @@ export class AccountService {
|
|||||||
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
|
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
|
||||||
if (this.currentUser !== undefined && this.currentUser !== null) {
|
if (this.currentUser !== undefined && this.currentUser !== null) {
|
||||||
this.currentUser.preferences = settings;
|
this.currentUser.preferences = settings;
|
||||||
this.setCurrentUser(this.currentUser);
|
this.setCurrentUser(this.currentUser, false);
|
||||||
|
|
||||||
// Update the locale on disk (for logout and compact-number pipe)
|
// Update the locale on disk (for logout and compact-number pipe)
|
||||||
localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale);
|
localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale);
|
||||||
|
@ -161,6 +161,7 @@ export class ActionFactoryService {
|
|||||||
|
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
|
|
||||||
|
|
||||||
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
||||||
this.accountService.currentUser$.subscribe((user) => {
|
this.accountService.currentUser$.subscribe((user) => {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
@ -39,7 +39,7 @@ const colorScapeSelector = 'colorscape';
|
|||||||
})
|
})
|
||||||
export class ColorscapeService {
|
export class ColorscapeService {
|
||||||
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
private colorSubject = new BehaviorSubject<ColorSpaceRGBA | null>(null);
|
||||||
public colors$ = this.colorSubject.asObservable();
|
public readonly colors$ = this.colorSubject.asObservable();
|
||||||
|
|
||||||
private minDuration = 1000; // minimum duration
|
private minDuration = 1000; // minimum duration
|
||||||
private maxDuration = 4000; // maximum duration
|
private maxDuration = 4000; // maximum duration
|
||||||
|
@ -20,6 +20,7 @@ import {Rating} from "../_models/rating";
|
|||||||
import {Recommendation} from "../_models/series-detail/recommendation";
|
import {Recommendation} from "../_models/series-detail/recommendation";
|
||||||
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
|
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
|
||||||
import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter";
|
import {NextExpectedChapter} from "../_models/series-detail/next-expected-chapter";
|
||||||
|
import {QueryContext} from "../_models/metadata/v2/query-context";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -33,12 +34,12 @@ export class SeriesService {
|
|||||||
constructor(private httpClient: HttpClient, private imageService: ImageService,
|
constructor(private httpClient: HttpClient, private imageService: ImageService,
|
||||||
private utilityService: UtilityService) { }
|
private utilityService: UtilityService) { }
|
||||||
|
|
||||||
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2) {
|
getAllSeriesV2(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilterV2, context: QueryContext = QueryContext.None) {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
|
||||||
const data = filter || {};
|
const data = filter || {};
|
||||||
|
|
||||||
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all-v2', data, {observe: 'response', params}).pipe(
|
return this.httpClient.post<PaginatedResult<Series[]>>(this.baseUrl + 'series/all-v2?context=' + context, data, {observe: 'response', params}).pipe(
|
||||||
map((response: any) => {
|
map((response: any) => {
|
||||||
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
|
return this.utilityService.createPaginatedResult(response, this.paginatedResults);
|
||||||
})
|
})
|
||||||
|
@ -17,6 +17,9 @@ export class ServerService {
|
|||||||
|
|
||||||
constructor(private http: HttpClient) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
|
getVersion(apiKey: string) {
|
||||||
|
return this.http.get<string>(this.baseUrl + 'plugin/version?apiKey=' + apiKey, TextResonse);
|
||||||
|
}
|
||||||
|
|
||||||
getServerInfo() {
|
getServerInfo() {
|
||||||
return this.http.get<ServerInfoSlim>(this.baseUrl + 'server/server-info-slim');
|
return this.http.get<ServerInfoSlim>(this.baseUrl + 'server/server-info-slim');
|
||||||
@ -38,6 +41,10 @@ export class ServerService {
|
|||||||
return this.http.post(this.baseUrl + 'server/analyze-files', {});
|
return this.http.post(this.baseUrl + 'server/analyze-files', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncThemes() {
|
||||||
|
return this.http.post(this.baseUrl + 'server/sync-themes', {});
|
||||||
|
}
|
||||||
|
|
||||||
checkForUpdate() {
|
checkForUpdate() {
|
||||||
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
|
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
|
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
|
||||||
<!-- Submenu items -->
|
<!-- Submenu items -->
|
||||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right-top"
|
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
|
||||||
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
||||||
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
|
||||||
(mouseleave)="preventEvent($event)">
|
(mouseleave)="preventEvent($event)">
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="details pb-3">
|
<div class="details pb-3">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h4 class="header">{{t('genres-title')}}</h4>
|
<h4 class="header">{{t('genres-title')}}</h4>
|
||||||
<app-badge-expander [includeComma]="true" [items]="genres">
|
<app-badge-expander [includeComma]="true" [items]="genres" [itemsTillExpander]="3">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Genres, item.id)">{{item.title}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Genres, item.id)">{{item.title}}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h4 class="header">{{t('tags-title')}}</h4>
|
<h4 class="header">{{t('tags-title')}}</h4>
|
||||||
<app-badge-expander [includeComma]="true" [items]="tags">
|
<app-badge-expander [includeComma]="true" [items]="tags" [itemsTillExpander]="3">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Tags, item.id)">{{item.title}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openGeneric(FilterField.Tags, item.id)">{{item.title}}</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -10,463 +10,478 @@
|
|||||||
<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" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
<li [ngbNavItem]="TabID.General">
|
@if (user && accountService.hasAdminRole(user))
|
||||||
<a ngbNavLink>{{t(TabID.General)}}</a>
|
{
|
||||||
<ng-template ngbNavContent>
|
<li [ngbNavItem]="TabID.General">
|
||||||
<div class="row g-0">
|
<a ngbNavLink>{{t(TabID.General)}}</a>
|
||||||
<div class="col-md-9 col-sm-12 mb-3">
|
<ng-template ngbNavContent>
|
||||||
<app-setting-item [title]="t('title-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
<div class="row g-0">
|
||||||
<ng-template #view>
|
<div class="col-md-9 col-sm-12 mb-3">
|
||||||
@if (editForm.get('titleName'); as formControl) {
|
<app-setting-item [title]="t('title-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||||
<div class="input-group" [ngClass]="{'lock-active': chapter.titleNameLocked}">
|
<ng-template #view>
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'titleNameLocked' }"></ng-container>
|
@if (editForm.get('titleName'); as formControl) {
|
||||||
<input class="form-control" formControlName="titleName" type="text"
|
<div class="input-group" [ngClass]="{'lock-active': chapter.titleNameLocked}">
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'titleNameLocked' }"></ng-container>
|
||||||
@if (formControl.errors; as errors) {
|
<input class="form-control" formControlName="titleName" type="text"
|
||||||
<div class="invalid-feedback">
|
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||||
@if (errors.required) {
|
@if (formControl.errors; as errors) {
|
||||||
<div>{{t('required-field')}}</div>
|
<div class="invalid-feedback">
|
||||||
}
|
@if (errors.required) {
|
||||||
</div>
|
<div>{{t('required-field')}}</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 col-sm-12 mb-3">
|
|
||||||
<app-setting-item [title]="t('sort-order-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
|
||||||
<ng-template #view>
|
|
||||||
@if (editForm.get('sortOrder'); as formControl) {
|
|
||||||
<div class="input-group" [ngClass]="{'lock-active': chapter.sortOrderLocked}">
|
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'sortOrderLocked' }"></ng-container>
|
|
||||||
<input class="form-control" formControlName="sortOrder" type="number" min="0" step="0.1" inputmode="numeric"
|
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
|
||||||
@if (formControl.errors; as errors) {
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
@if (errors.required) {
|
|
||||||
<div>{{t('required-field')}}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-md-9 col-sm-12 mb-3">
|
|
||||||
<app-setting-item [title]="t('isbn-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
|
||||||
<ng-template #view>
|
|
||||||
@if (editForm.get('isbn'); as formControl) {
|
|
||||||
<div class="input-group" [ngClass]="{'lock-active': chapter.isbnLocked}">
|
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'isbnLocked' }"></ng-container>
|
|
||||||
<input class="form-control" formControlName="isbn" type="text"
|
|
||||||
[class.is-invalid]="formControl.invalid && formControl.touched">
|
|
||||||
@if (formControl.errors; as errors) {
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
@if (errors.required) {
|
|
||||||
<div>{{t('required-field')}}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-md-3 col-sm-12 mb-3">
|
|
||||||
<app-setting-item [title]="t('age-rating-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
|
||||||
<ng-template #view>
|
|
||||||
@if (editForm.get('ageRating'); as formControl) {
|
|
||||||
<div class="input-group" [ngClass]="{'lock-active': chapter.ageRatingLocked}">
|
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'ageRatingLocked' }"></ng-container>
|
|
||||||
<select class="form-select" id="age-rating" formControlName="ageRating">
|
|
||||||
@for(opt of ageRatings; track opt.value) {
|
|
||||||
<option [value]="opt.value">{{opt.title | titlecase}}</option>
|
|
||||||
}
|
}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</ng-template>
|
||||||
</ng-template>
|
</app-setting-item>
|
||||||
</app-setting-item>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0">
|
<div class="col-md-3 col-sm-12 mb-3">
|
||||||
<div class="col-lg-9 col-md-12">
|
<app-setting-item [title]="t('sort-order-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||||
<div class="mb-3">
|
|
||||||
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
|
@if (editForm.get('sortOrder'); as formControl) {
|
||||||
[(locked)]="chapter.languageLocked" (onUnlock)="chapter.languageLocked = false"
|
<div class="input-group" [ngClass]="{'lock-active': chapter.sortOrderLocked}">
|
||||||
(newItemAdded)="chapter.languageLocked = true">
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'sortOrderLocked' }"></ng-container>
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
<input class="form-control" formControlName="sortOrder" type="number" min="0" step="0.1" inputmode="numeric"
|
||||||
{{item.title}}
|
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||||
</ng-template>
|
@if (formControl.errors; as errors) {
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
<div class="invalid-feedback">
|
||||||
{{item.title}} ({{item.isoCode}})
|
@if (errors.required) {
|
||||||
</ng-template>
|
<div>{{t('required-field')}}</div>
|
||||||
</app-typeahead>
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-3 col-md-12">
|
<div class="row g-0">
|
||||||
<div class="mb-3">
|
<div class="col-md-9 col-sm-12 mb-3">
|
||||||
<app-setting-item [title]="t('release-date-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('isbn-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<div class="input-group" [ngClass]="{'lock-active': chapter.releaseDateLocked}">
|
@if (editForm.get('isbn'); as formControl) {
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'releaseDateLocked' }"></ng-container>
|
<div class="input-group" [ngClass]="{'lock-active': chapter.isbnLocked}">
|
||||||
<input
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'isbnLocked' }"></ng-container>
|
||||||
class="form-control"
|
<input class="form-control" formControlName="isbn" type="text"
|
||||||
formControlName="releaseDate"
|
[class.is-invalid]="formControl.invalid && formControl.touched">
|
||||||
type="date"
|
@if (formControl.errors; as errors) {
|
||||||
/>
|
<div class="invalid-feedback">
|
||||||
</div>
|
@if (errors.required) {
|
||||||
|
<div>{{t('required-field')}}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 col-sm-12 mb-3">
|
||||||
|
<app-setting-item [title]="t('age-rating-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||||
|
<ng-template #view>
|
||||||
|
@if (editForm.get('ageRating'); as formControl) {
|
||||||
|
<div class="input-group" [ngClass]="{'lock-active': chapter.ageRatingLocked}">
|
||||||
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'ageRatingLocked' }"></ng-container>
|
||||||
|
<select class="form-select" id="age-rating" formControlName="ageRating">
|
||||||
|
@for(opt of ageRatings; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="mb-3" style="width: 100%">
|
<div class="col-lg-9 col-md-12">
|
||||||
<app-setting-item [title]="t('summary-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
<div class="mb-3">
|
||||||
<ng-template #view>
|
<app-setting-item [title]="t('language-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
@if (editForm.get('summary'); as formControl) {
|
<ng-template #view>
|
||||||
<div class="input-group" [ngClass]="{'lock-active': chapter.summaryLocked}">
|
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings"
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'summaryLocked' }"></ng-container>
|
[(locked)]="chapter.languageLocked" (onUnlock)="chapter.languageLocked = false"
|
||||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
(newItemAdded)="chapter.languageLocked = true">
|
||||||
</div>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
}
|
{{item.title}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.title}} ({{item.isoCode}})
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-3 col-md-12">
|
||||||
|
<div class="mb-3">
|
||||||
|
<app-setting-item [title]="t('release-date-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
|
<ng-template #view>
|
||||||
|
<div class="input-group" [ngClass]="{'lock-active': chapter.releaseDateLocked}">
|
||||||
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'releaseDateLocked' }"></ng-container>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
formControlName="releaseDate"
|
||||||
|
type="date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</ng-template>
|
<div class="row g-0">
|
||||||
</li>
|
<div class="mb-3" style="width: 100%">
|
||||||
|
<app-setting-item [title]="t('summary-label')" [showEdit]="false" [toggleOnViewClick]="false">
|
||||||
|
<ng-template #view>
|
||||||
|
@if (editForm.get('summary'); as formControl) {
|
||||||
|
<div class="input-group" [ngClass]="{'lock-active': chapter.summaryLocked}">
|
||||||
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: chapter, field: 'summaryLocked' }"></ng-container>
|
||||||
|
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
<!-- Tags Tab -->
|
<!-- Tags Tab -->
|
||||||
<li [ngbNavItem]="TabID.Tags">
|
@if (user && accountService.hasAdminRole(user))
|
||||||
<a ngbNavLink>{{t(TabID.Tags)}}</a>
|
{
|
||||||
<ng-template ngbNavContent>
|
<li [ngbNavItem]="TabID.Tags">
|
||||||
<!-- genre & tag -->
|
<a ngbNavLink>{{t(TabID.Tags)}}</a>
|
||||||
<div class="row g-0">
|
<ng-template ngbNavContent>
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- genre & tag -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updateGenres($event);chapter.genresLocked = true" [settings]="genreSettings"
|
<app-setting-item [title]="t('genres-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.genresLocked" (onUnlock)="chapter.genresLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.genresLocked = true">
|
<app-typeahead (selectedData)="updateGenres($event);chapter.genresLocked = true" [settings]="genreSettings"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.genresLocked" (onUnlock)="chapter.genresLocked = false"
|
||||||
{{item.title}}
|
(newItemAdded)="chapter.genresLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.title}}
|
||||||
{{item.title}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.title}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
|
<div class="mb-3">
|
||||||
|
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
|
<ng-template #view>
|
||||||
|
<app-typeahead (selectedData)="updateTags($event);chapter.tagsLocked = true" [settings]="tagsSettings"
|
||||||
|
[(locked)]="chapter.tagsLocked" (onUnlock)="chapter.tagsLocked = false"
|
||||||
|
(newItemAdded)="chapter.tagsLocked = true">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- imprint & publisher -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('tags-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updateTags($event);chapter.tagsLocked = true" [settings]="tagsSettings"
|
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.tagsLocked" (onUnlock)="chapter.tagsLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.tagsLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);chapter.imprintLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.imprintLocked" (onUnlock)="chapter.imprintLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.imprintLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- imprint & publisher -->
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<div class="row g-0">
|
<div class="mb-3">
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<div class="mb-3">
|
<ng-template #view>
|
||||||
<app-setting-item [title]="t('imprint-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);chapter.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
||||||
<ng-template #view>
|
[(locked)]="chapter.publisherLocked" (onUnlock)="chapter.publisherLocked = false"
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);chapter.imprintLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
|
(newItemAdded)="chapter.publisherLocked = true">
|
||||||
[(locked)]="chapter.imprintLocked" (onUnlock)="chapter.imprintLocked = false"
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
(newItemAdded)="chapter.imprintLocked = true">
|
{{item.name}}
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</ng-template>
|
{{item.name}}
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
</app-typeahead>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-typeahead>
|
</app-setting-item>
|
||||||
</ng-template>
|
</div>
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- team & location -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('publisher-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Publisher);chapter.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Publisher)"
|
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.publisherLocked" (onUnlock)="chapter.publisherLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.publisherLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);chapter.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.teamLocked" (onUnlock)="chapter.teamLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.teamLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- team & location -->
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<div class="row g-0">
|
<div class="mb-3">
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<div class="mb-3">
|
<ng-template #view>
|
||||||
<app-setting-item [title]="t('team-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);chapter.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
|
||||||
<ng-template #view>
|
[(locked)]="chapter.locationLocked" (onUnlock)="chapter.locationLocked = false"
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Team);chapter.teamLocked = true" [settings]="getPersonsSettings(PersonRole.Team)"
|
(newItemAdded)="chapter.locationLocked = true">
|
||||||
[(locked)]="chapter.teamLocked" (onUnlock)="chapter.teamLocked = false"
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
(newItemAdded)="chapter.teamLocked = true">
|
{{item.name}}
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</ng-template>
|
{{item.name}}
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
</app-typeahead>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-typeahead>
|
</app-setting-item>
|
||||||
</ng-template>
|
</div>
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- character -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('location-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-12 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Location);chapter.locationLocked = true" [settings]="getPersonsSettings(PersonRole.Location)"
|
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.locationLocked" (onUnlock)="chapter.locationLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.locationLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);chapter.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.characterLocked" (onUnlock)="chapter.characterLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.characterLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- character -->
|
</ng-template>
|
||||||
<div class="row g-0">
|
</li>
|
||||||
<div class="col-lg-12 col-md-12 pe-2">
|
}
|
||||||
<div class="mb-3">
|
|
||||||
<app-setting-item [title]="t('character-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
|
||||||
<ng-template #view>
|
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Character);chapter.characterLocked = true" [settings]="getPersonsSettings(PersonRole.Character)"
|
|
||||||
[(locked)]="chapter.characterLocked" (onUnlock)="chapter.characterLocked = false"
|
|
||||||
(newItemAdded)="chapter.characterLocked = true">
|
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
|
||||||
{{item.name}}
|
|
||||||
</ng-template>
|
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
|
||||||
{{item.name}}
|
|
||||||
</ng-template>
|
|
||||||
</app-typeahead>
|
|
||||||
</ng-template>
|
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</ng-template>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- People Tab -->
|
<!-- People Tab -->
|
||||||
<li [ngbNavItem]="TabID.People">
|
@if (user && accountService.hasAdminRole(user))
|
||||||
<a ngbNavLink>{{t(TabID.People)}}</a>
|
{
|
||||||
<ng-template ngbNavContent>
|
<li [ngbNavItem]="TabID.People">
|
||||||
<!-- writer & cover artist -->
|
<a ngbNavLink>{{t(TabID.People)}}</a>
|
||||||
<div class="row g-0">
|
<ng-template ngbNavContent>
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- writer & cover artist -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);chapter.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
|
<app-setting-item [title]="t('writer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.writerLocked" (onUnlock)="chapter.writerLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.writerLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Writer);chapter.writerLocked = true" [settings]="getPersonsSettings(PersonRole.Writer)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.writerLocked" (onUnlock)="chapter.writerLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.writerLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
|
<div class="mb-3">
|
||||||
|
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
|
<ng-template #view>
|
||||||
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);chapter.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
||||||
|
[(locked)]="chapter.coverArtistLocked" (onUnlock)="chapter.coverArtistLocked = false"
|
||||||
|
(newItemAdded)="chapter.coverArtistLocked = true">
|
||||||
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
|
{{item.name}}
|
||||||
|
</ng-template>
|
||||||
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- penciller & colorist -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('cover-artist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.CoverArtist);chapter.coverArtistLocked = true" [settings]="getPersonsSettings(PersonRole.CoverArtist)"
|
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.coverArtistLocked" (onUnlock)="chapter.coverArtistLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.coverArtistLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);chapter.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.pencillerLocked" (onUnlock)="chapter.pencillerLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.pencillerLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- penciller & colorist -->
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<div class="row g-0">
|
<div class="mb-3">
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<div class="mb-3">
|
<ng-template #view>
|
||||||
<app-setting-item [title]="t('penciller-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);chapter.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
|
||||||
<ng-template #view>
|
[(locked)]="chapter.coloristLocked" (onUnlock)="chapter.coloristLocked = false"
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Penciller);chapter.pencillerLocked = true" [settings]="getPersonsSettings(PersonRole.Penciller)"
|
(newItemAdded)="chapter.coloristLocked = true">
|
||||||
[(locked)]="chapter.pencillerLocked" (onUnlock)="chapter.pencillerLocked = false"
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
(newItemAdded)="chapter.pencillerLocked = true">
|
{{item.name}}
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</ng-template>
|
{{item.name}}
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
</app-typeahead>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-typeahead>
|
</app-setting-item>
|
||||||
</ng-template>
|
</div>
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- inker & letterer -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('colorist-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Colorist);chapter.coloristLocked = true" [settings]="getPersonsSettings(PersonRole.Colorist)"
|
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.coloristLocked" (onUnlock)="chapter.coloristLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.coloristLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);chapter.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.inkerLocked" (onUnlock)="chapter.inkerLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.inkerLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- inker & letterer -->
|
<div class="col-lg-6 col-md-12 pe-2">
|
||||||
<div class="row g-0">
|
<div class="mb-3">
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<div class="mb-3">
|
<ng-template #view>
|
||||||
<app-setting-item [title]="t('inker-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);chapter.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
|
||||||
<ng-template #view>
|
[(locked)]="chapter.lettererLocked" (onUnlock)="chapter.lettererLocked = false"
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Inker);chapter.inkerLocked = true" [settings]="getPersonsSettings(PersonRole.Inker)"
|
(newItemAdded)="chapter.lettererLocked = true">
|
||||||
[(locked)]="chapter.inkerLocked" (onUnlock)="chapter.inkerLocked = false"
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
(newItemAdded)="chapter.inkerLocked = true">
|
{{item.name}}
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</ng-template>
|
{{item.name}}
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
</ng-template>
|
||||||
{{item.name}}
|
</app-typeahead>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-typeahead>
|
</app-setting-item>
|
||||||
</ng-template>
|
</div>
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6 col-md-12 pe-2">
|
<!-- translator -->
|
||||||
<div class="mb-3">
|
<div class="row g-0">
|
||||||
<app-setting-item [title]="t('letterer-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="col-lg-12 col-md-12 pe-2">
|
||||||
<ng-template #view>
|
<div class="mb-3">
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Letterer);chapter.lettererLocked = true" [settings]="getPersonsSettings(PersonRole.Letterer)"
|
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
[(locked)]="chapter.lettererLocked" (onUnlock)="chapter.lettererLocked = false"
|
<ng-template #view>
|
||||||
(newItemAdded)="chapter.lettererLocked = true">
|
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);chapter.translatorLocked = true" [settings]="getPersonsSettings(PersonRole.Translator)"
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
[(locked)]="chapter.translatorLocked" (onUnlock)="chapter.translatorLocked = false"
|
||||||
{{item.name}}
|
(newItemAdded)="chapter.translatorLocked = true">
|
||||||
</ng-template>
|
<ng-template #badgeItem let-item let-position="idx">
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
{{item.name}}
|
||||||
{{item.name}}
|
</ng-template>
|
||||||
</ng-template>
|
<ng-template #optionItem let-item let-position="idx">
|
||||||
</app-typeahead>
|
{{item.name}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-typeahead>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- translator -->
|
</ng-template>
|
||||||
<div class="row g-0">
|
</li>
|
||||||
<div class="col-lg-12 col-md-12 pe-2">
|
}
|
||||||
<div class="mb-3">
|
|
||||||
<app-setting-item [title]="t('translator-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
|
||||||
<ng-template #view>
|
|
||||||
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Translator);chapter.translatorLocked = true" [settings]="getPersonsSettings(PersonRole.Translator)"
|
|
||||||
[(locked)]="chapter.translatorLocked" (onUnlock)="chapter.translatorLocked = false"
|
|
||||||
(newItemAdded)="chapter.translatorLocked = true">
|
|
||||||
<ng-template #badgeItem let-item let-position="idx">
|
|
||||||
{{item.name}}
|
|
||||||
</ng-template>
|
|
||||||
<ng-template #optionItem let-item let-position="idx">
|
|
||||||
{{item.name}}
|
|
||||||
</ng-template>
|
|
||||||
</app-typeahead>
|
|
||||||
</ng-template>
|
|
||||||
</app-setting-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</ng-template>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Cover Tab -->
|
<!-- Cover Tab -->
|
||||||
<li [ngbNavItem]="TabID.CoverImage">
|
@if (user && accountService.hasAdminRole(user))
|
||||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
{
|
||||||
<ng-template ngbNavContent>
|
<li [ngbNavItem]="TabID.CoverImage">
|
||||||
<p class="alert alert-warning" role="alert">
|
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||||
{{t('cover-image-description')}}
|
<ng-template ngbNavContent>
|
||||||
</p>
|
<p class="alert alert-warning" role="alert">
|
||||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
|
{{t('cover-image-description')}}
|
||||||
[showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
</p>
|
||||||
</ng-template>
|
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
|
||||||
</li>
|
[showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Info Tab -->
|
<!-- Info Tab -->
|
||||||
<li [ngbNavItem]="TabID.Info">
|
<li [ngbNavItem]="TabID.Info">
|
||||||
@ -539,30 +554,34 @@
|
|||||||
|
|
||||||
@if (WebLinks.length > 0) {
|
@if (WebLinks.length > 0) {
|
||||||
<div class="setting-section-break"></div>
|
<div class="setting-section-break"></div>
|
||||||
<div class="row g-0">
|
|
||||||
<div class="col-auto">
|
<div class="container-fluid mb-3">
|
||||||
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
|
<div class="row g-0">
|
||||||
|
<h6 class="section-title">{{t('links-label')}}</h6>
|
||||||
|
<div class="col-auto">
|
||||||
@for(link of WebLinks; track link) {
|
@for(link of WebLinks; track link) {
|
||||||
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
|
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</app-icon-and-title>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (accountService.isAdmin$ | async) {
|
@if (accountService.isAdmin$ | async) {
|
||||||
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<div class="row g-0">
|
||||||
<ng-template #view>
|
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
@for (file of chapter.files; track file.id) {
|
<ng-template #view>
|
||||||
<div>
|
@for (file of chapter.files; track file.id) {
|
||||||
<span>{{file.filePath}}</span><span class="ms-2 me-2">•</span><span>{{file.bytes | bytes}}</span>
|
<div>
|
||||||
</div>
|
<span>{{file.filePath}}</span><span class="ms-2 me-2">•</span><span>{{file.bytes | bytes}}</span>
|
||||||
}
|
</div>
|
||||||
</ng-template>
|
}
|
||||||
</app-setting-item>
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -581,11 +600,13 @@
|
|||||||
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
|
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@for(task of tasks; track task.action) {
|
@for(task of tasks; track task.action) {
|
||||||
<div class="mt-3 mb-3">
|
@if (accountService.canInvokeAction(user, task.action)) {
|
||||||
<app-setting-button [subtitle]="task.description">
|
<div class="mt-3 mb-3">
|
||||||
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
<app-setting-button [subtitle]="task.description">
|
||||||
</app-setting-button>
|
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
||||||
</div>
|
</app-setting-button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
@ -36,7 +36,7 @@ import {ActionService} from "../../_services/action.service";
|
|||||||
import {DownloadService} from "../../shared/_services/download.service";
|
import {DownloadService} from "../../shared/_services/download.service";
|
||||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||||
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
|
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
|
||||||
import {forkJoin, Observable, of} from "rxjs";
|
import {forkJoin, Observable, of, tap} from "rxjs";
|
||||||
import {map} from "rxjs/operators";
|
import {map} from "rxjs/operators";
|
||||||
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
|
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
|
||||||
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
|
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
|
||||||
@ -55,6 +55,8 @@ import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
|||||||
import {ReadTimePipe} from "../../_pipes/read-time.pipe";
|
import {ReadTimePipe} from "../../_pipes/read-time.pipe";
|
||||||
import {ChapterService} from "../../_services/chapter.service";
|
import {ChapterService} from "../../_services/chapter.service";
|
||||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||||
|
import {User} from "../../_models/user";
|
||||||
|
import {SettingTitleComponent} from "../../settings/_components/setting-title/setting-title.component";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 'general-tab',
|
General = 'general-tab',
|
||||||
@ -109,7 +111,8 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
|
|||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
DecimalPipe,
|
DecimalPipe,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
ReadTimePipe
|
ReadTimePipe,
|
||||||
|
SettingTitleComponent
|
||||||
],
|
],
|
||||||
templateUrl: './edit-chapter-modal.component.html',
|
templateUrl: './edit-chapter-modal.component.html',
|
||||||
styleUrl: './edit-chapter-modal.component.scss',
|
styleUrl: './edit-chapter-modal.component.scss',
|
||||||
@ -163,6 +166,7 @@ export class EditChapterModalComponent implements OnInit {
|
|||||||
initChapter!: Chapter;
|
initChapter!: Chapter;
|
||||||
imageUrls: Array<string> = [];
|
imageUrls: Array<string> = [];
|
||||||
size: number = 0;
|
size: number = 0;
|
||||||
|
user!: User;
|
||||||
|
|
||||||
get WebLinks() {
|
get WebLinks() {
|
||||||
if (this.chapter.webLinks === '') return [];
|
if (this.chapter.webLinks === '') return [];
|
||||||
@ -176,7 +180,16 @@ export class EditChapterModalComponent implements OnInit {
|
|||||||
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
||||||
|
|
||||||
this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0);
|
this.size = this.utilityService.asChapter(this.chapter).files.reduce((sum, v) => sum + v.bytes, 0);
|
||||||
|
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), tap(u => {
|
||||||
|
if (!u) return;
|
||||||
|
this.user = u;
|
||||||
|
|
||||||
|
if (!this.accountService.hasAdminRole(this.user)) {
|
||||||
|
this.activeId = TabID.Info;
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
|
})).subscribe();
|
||||||
|
|
||||||
this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, []));
|
this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, []));
|
||||||
this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)]));
|
this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)]));
|
||||||
@ -239,6 +252,7 @@ export class EditChapterModalComponent implements OnInit {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
this.modal.dismiss();
|
this.modal.dismiss();
|
||||||
}
|
}
|
||||||
|
@ -8,18 +8,6 @@
|
|||||||
<form [formGroup]="editForm">
|
<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" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||||
|
|
||||||
<!-- Cover Tab -->
|
|
||||||
<li [ngbNavItem]="TabID.CoverImage">
|
|
||||||
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
|
||||||
<ng-template ngbNavContent>
|
|
||||||
<p class="alert alert-warning" role="alert">
|
|
||||||
{{t('cover-image-description')}}
|
|
||||||
</p>
|
|
||||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
|
|
||||||
[showReset]="true" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
|
||||||
</ng-template>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Info Tab -->
|
<!-- Info Tab -->
|
||||||
<li [ngbNavItem]="TabID.Info">
|
<li [ngbNavItem]="TabID.Info">
|
||||||
<a ngbNavLink>{{t(TabID.Info)}}</a>
|
<a ngbNavLink>{{t(TabID.Info)}}</a>
|
||||||
@ -88,7 +76,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (accountService.isAdmin$ | async) {
|
@if (user && accountService.hasAdminRole(user)) {
|
||||||
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('files-label')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
@for (file of files; track file.id) {
|
@for (file of files; track file.id) {
|
||||||
@ -103,6 +91,20 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<!-- Cover Tab -->
|
||||||
|
@if (user && accountService.hasAdminRole(user)) {
|
||||||
|
<li [ngbNavItem]="TabID.CoverImage">
|
||||||
|
<a ngbNavLink>{{t(TabID.CoverImage)}}</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<p class="alert alert-warning" role="alert">
|
||||||
|
{{t('cover-image-description')}}
|
||||||
|
</p>
|
||||||
|
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)"
|
||||||
|
[showReset]="true" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Progress Tab -->
|
<!-- Progress Tab -->
|
||||||
<li [ngbNavItem]="TabID.Progress">
|
<li [ngbNavItem]="TabID.Progress">
|
||||||
<a ngbNavLink>{{t(TabID.Progress)}}</a>
|
<a ngbNavLink>{{t(TabID.Progress)}}</a>
|
||||||
@ -120,11 +122,13 @@
|
|||||||
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
|
<a ngbNavLink>{{t(TabID.Tasks)}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@for(task of tasks; track task.action) {
|
@for(task of tasks; track task.action) {
|
||||||
<div class="mt-3 mb-3">
|
@if (accountService.canInvokeAction(user, task.action)) {
|
||||||
<app-setting-button [subtitle]="task.description">
|
<div class="mt-3 mb-3">
|
||||||
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
<app-setting-button [subtitle]="task.description">
|
||||||
</app-setting-button>
|
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
||||||
</div>
|
</app-setting-button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
@ -40,6 +40,8 @@ import {forkJoin} from "rxjs";
|
|||||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||||
import {MangaFile} from "../../_models/manga-file";
|
import {MangaFile} from "../../_models/manga-file";
|
||||||
import {VolumeService} from "../../_services/volume.service";
|
import {VolumeService} from "../../_services/volume.service";
|
||||||
|
import {User} from "../../_models/user";
|
||||||
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 'general-tab',
|
General = 'general-tab',
|
||||||
@ -121,10 +123,11 @@ export class EditVolumeModalComponent implements OnInit {
|
|||||||
@Input({required: true}) libraryId!: number;
|
@Input({required: true}) libraryId!: number;
|
||||||
@Input({required: true}) seriesId!: number;
|
@Input({required: true}) seriesId!: number;
|
||||||
|
|
||||||
activeId = TabID.CoverImage;
|
activeId = TabID.Info;
|
||||||
editForm: FormGroup = new FormGroup({});
|
editForm: FormGroup = new FormGroup({});
|
||||||
selectedCover: string = '';
|
selectedCover: string = '';
|
||||||
coverImageReset = false;
|
coverImageReset = false;
|
||||||
|
user!: User;
|
||||||
|
|
||||||
|
|
||||||
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList);
|
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getVolumeActions(this.runTask.bind(this)), blackList);
|
||||||
@ -136,6 +139,16 @@ export class EditVolumeModalComponent implements OnInit {
|
|||||||
size: number = 0;
|
size: number = 0;
|
||||||
files: Array<MangaFile> = [];
|
files: Array<MangaFile> = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.accountService.currentUser$.subscribe(user => {
|
||||||
|
this.user = user!;
|
||||||
|
|
||||||
|
if (!this.accountService.hasAdminRole(user!)) {
|
||||||
|
this.activeId = TabID.Info;
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-card-item [title]="item.title" [entity]="item"
|
<app-card-item [title]="item.title" [entity]="item"
|
||||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
[suppressLibraryLink]="true" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
|
||||||
(clicked)="openCollection(item)" [linkUrl]="'/collections/' + item.id"></app-card-item>
|
(clicked)="openCollection(item)" [linkUrl]="'/collections/' + item.id" [showFormat]="false"></app-card-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
}
|
}
|
||||||
@ -24,7 +24,7 @@
|
|||||||
<ng-template #carouselItem let-item>
|
<ng-template #carouselItem let-item>
|
||||||
<app-card-item [title]="item.title" [entity]="item"
|
<app-card-item [title]="item.title" [entity]="item"
|
||||||
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
|
||||||
(clicked)="openReadingList(item)" [linkUrl]="'/lists/' + item.id"></app-card-item>
|
(clicked)="openReadingList(item)" [linkUrl]="'/lists/' + item.id" [showFormat]="false"></app-card-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
}
|
}
|
||||||
|
@ -9,42 +9,49 @@
|
|||||||
<div class="modal-body scrollable-modal">
|
<div class="modal-body scrollable-modal">
|
||||||
|
|
||||||
<form [formGroup]="userForm">
|
<form [formGroup]="userForm">
|
||||||
<div class="row g-0">
|
<h4>{{t('account-detail-title')}}</h4>
|
||||||
<div class="col-md-6 col-sm-12 pe-4">
|
<div class="row g-0 mb-2">
|
||||||
<div class="mb-3">
|
<div class="col-md-6 col-sm-12 pe-4">
|
||||||
<label for="username" class="form-label">{{t('username')}}</label>
|
<div class="mb-3">
|
||||||
<input id="username" class="form-control" formControlName="username" type="text"
|
<label for="username" class="form-label">{{t('username')}}</label>
|
||||||
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
|
<input id="username" class="form-control" formControlName="username" type="text"
|
||||||
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
[class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched" aria-describedby="username-validations">
|
||||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
<div id="username-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||||
{{t('required')}}
|
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||||
</div>
|
{{t('required')}}
|
||||||
<div *ngIf="userForm.get('username')?.errors?.pattern">
|
</div>
|
||||||
{{t('username-pattern', {characters: allowedCharacters})}}
|
<div *ngIf="userForm.get('username')?.errors?.pattern">
|
||||||
</div>
|
{{t('username-pattern', {characters: allowedCharacters})}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-sm-12">
|
</div>
|
||||||
<div class="mb-3" style="width:100%">
|
<div class="col-md-6 col-sm-12">
|
||||||
<label for="email" class="form-label">{{t('email')}}</label>
|
<div class="mb-3" style="width:100%">
|
||||||
<input class="form-control" inputmode="email" type="email" id="email"
|
<label for="email" class="form-label">{{t('email')}}</label>
|
||||||
[class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched"
|
<input class="form-control" inputmode="email" type="email" id="email"
|
||||||
formControlName="email" aria-describedby="email-validations">
|
[class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched"
|
||||||
<div id="email-validations" class="invalid-feedback"
|
formControlName="email" aria-describedby="email-validations">
|
||||||
*ngIf="userForm.dirty || userForm.touched">
|
<div id="email-validations" class="invalid-feedback"
|
||||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
*ngIf="userForm.dirty || userForm.touched">
|
||||||
{{t('required')}}
|
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||||
</div>
|
{{t('required')}}
|
||||||
<div *ngIf="userForm.get('email')?.errors?.email">
|
</div>
|
||||||
{{t('not-valid-email')}}
|
<div *ngIf="userForm.get('email')?.errors?.email">
|
||||||
</div>
|
{{t('not-valid-email')}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-0">
|
<div class="row g-0 mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-0 mb-3">
|
||||||
<div class="col-md-6 pe-4">
|
<div class="col-md-6 pe-4">
|
||||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||||
</div>
|
</div>
|
||||||
@ -53,12 +60,6 @@
|
|||||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 mt-3">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: We need a filter bar when there is more than 10 libraries -->
|
||||||
|
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -31,15 +33,13 @@
|
|||||||
{{library.lastScanned | timeAgo | defaultDate}}
|
{{library.lastScanned | timeAgo | defaultDate}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="float-end">
|
@if (useActionables$ | async) {
|
||||||
@if (useActionables$ | async) {
|
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
||||||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
} @else {
|
||||||
} @else {
|
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
|
||||||
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button>
|
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
||||||
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
||||||
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button>
|
}
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
@ -105,6 +105,12 @@ export class ManageTasksSettingsComponent implements OnInit {
|
|||||||
api: this.serverService.analyzeFiles(),
|
api: this.serverService.analyzeFiles(),
|
||||||
successMessage: 'analyze-files-task-success'
|
successMessage: 'analyze-files-task-success'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sync-themes-task',
|
||||||
|
description: 'sync-themes-desc',
|
||||||
|
api: this.serverService.syncThemes(),
|
||||||
|
successMessage: 'sync-themes-success'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'check-for-updates-task',
|
name: 'check-for-updates-task',
|
||||||
description: 'check-for-updates-task-desc',
|
description: 'check-for-updates-task-desc',
|
||||||
|
@ -1,20 +1,19 @@
|
|||||||
<ng-container *transloco="let t; read: 'all-filters'">
|
<ng-container *transloco="let t; read: 'all-filters'">
|
||||||
<app-side-nav-companion-bar [hasFilter]="false">
|
<div class="main-container">
|
||||||
<h2 title>
|
<app-side-nav-companion-bar [hasFilter]="false">
|
||||||
{{t('title')}}
|
<h4 title>
|
||||||
</h2>
|
{{t('title')}}
|
||||||
<div subtitle>
|
</h4>
|
||||||
<h6>
|
<div subtitle>
|
||||||
<span>{{t('count', {count: filters.length | number})}}</span>
|
<h6>
|
||||||
<a class="ms-2" href="/all-series?name=New%20Filter">{{t('create')}}</a>
|
<span>{{t('count', {count: filters.length | number})}}</span>
|
||||||
</h6>
|
<a class="ms-2" href="/all-series?name=New%20Filter">{{t('create')}}</a>
|
||||||
|
</h6>
|
||||||
</div>
|
|
||||||
|
|
||||||
</app-side-nav-companion-bar>
|
|
||||||
|
|
||||||
<app-manage-smart-filters></app-manage-smart-filters>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app-side-nav-companion-bar>
|
||||||
|
|
||||||
|
<app-manage-smart-filters></app-manage-smart-filters>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -1,2 +1,4 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
@ -1,9 +1,9 @@
|
|||||||
<div class="main-container container-fluid">
|
<div class="main-container container-fluid">
|
||||||
<ng-container *transloco="let t; read: 'announcements'">
|
<ng-container *transloco="let t; read: 'announcements'">
|
||||||
<app-side-nav-companion-bar>
|
<app-side-nav-companion-bar>
|
||||||
<h2 title>
|
<h4 title>
|
||||||
{{t('title')}}
|
{{t('title')}}
|
||||||
</h2>
|
</h4>
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
|
|
||||||
<app-changelog></app-changelog>
|
<app-changelog></app-changelog>
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
@ -22,6 +22,7 @@ import {ServerService} from "./_services/server.service";
|
|||||||
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
||||||
import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component";
|
import {PreferenceNavComponent} from "./sidenav/preference-nav/preference-nav.component";
|
||||||
import {Breakpoint, UtilityService} from "./shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "./shared/_services/utility.service";
|
||||||
|
import {translate} from "@jsverse/transloco";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@ -88,6 +89,8 @@ export class AppComponent implements OnInit {
|
|||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
return user.preferences.noTransitions;
|
return user.preferences.noTransitions;
|
||||||
}), takeUntilDestroyed(this.destroyRef));
|
}), takeUntilDestroyed(this.destroyRef));
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize', ['$event'])
|
@HostListener('window:resize', ['$event'])
|
||||||
@ -110,28 +113,40 @@ export class AppComponent implements OnInit {
|
|||||||
const user = this.accountService.getUserFromLocalStorage();
|
const user = this.accountService.getUserFromLocalStorage();
|
||||||
this.accountService.setCurrentUser(user);
|
this.accountService.setCurrentUser(user);
|
||||||
|
|
||||||
if (user) {
|
if (!user) return;
|
||||||
// Bootstrap anything that's needed
|
|
||||||
this.themeService.getThemes().subscribe();
|
|
||||||
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
|
|
||||||
|
|
||||||
// Every hour, have the UI check for an update. People seriously stay out of date
|
// Bootstrap anything that's needed
|
||||||
interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
|
this.themeService.getThemes().subscribe();
|
||||||
.pipe(
|
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
|
||||||
switchMap(() => this.accountService.currentUser$),
|
|
||||||
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
|
// Get the server version, compare vs localStorage, and if different bust locale cache
|
||||||
switchMap(_ => this.serverService.checkHowOutOfDate()),
|
this.serverService.getVersion(user.apiKey).subscribe(version => {
|
||||||
filter(versionOutOfDate => {
|
const cachedVersion = localStorage.getItem('kavita--version');
|
||||||
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
|
if (cachedVersion == null || cachedVersion != version) {
|
||||||
}),
|
// Bust locale cache
|
||||||
tap(versionOutOfDate => {
|
localStorage.removeItem('@transloco/translations/timestamp');
|
||||||
if (!this.ngbModal.hasOpenModals()) {
|
localStorage.removeItem('@transloco/translations');
|
||||||
const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
|
location.reload();
|
||||||
ref.componentInstance.versionsOutOfDate = versionOutOfDate;
|
}
|
||||||
}
|
localStorage.setItem('kavita--version', version);
|
||||||
})
|
});
|
||||||
)
|
|
||||||
.subscribe();
|
// Every hour, have the UI check for an update. People seriously stay out of date
|
||||||
}
|
interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => this.accountService.currentUser$),
|
||||||
|
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
|
||||||
|
switchMap(_ => this.serverService.checkHowOutOfDate()),
|
||||||
|
filter(versionOutOfDate => {
|
||||||
|
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
|
||||||
|
}),
|
||||||
|
tap(versionOutOfDate => {
|
||||||
|
if (!this.ngbModal.hasOpenModals()) {
|
||||||
|
const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||||
|
ref.componentInstance.versionsOutOfDate = versionOutOfDate;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
@ -712,12 +712,16 @@
|
|||||||
<li [ngbNavItem]="tabs[TabID.Tasks]">
|
<li [ngbNavItem]="tabs[TabID.Tasks]">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.Tasks])}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.Tasks])}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@for(task of tasks; track task.action) {
|
@if (accountService.currentUser$ | async; as user) {
|
||||||
<div class="mt-3 mb-3">
|
@for(task of tasks; track task.action) {
|
||||||
<app-setting-button [subtitle]="task.description">
|
@if (accountService.canInvokeAction(user, task.action)) {
|
||||||
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
<div class="mt-3 mb-3">
|
||||||
</app-setting-button>
|
<app-setting-button [subtitle]="task.description">
|
||||||
</div>
|
<button class="btn btn-{{task.action === Action.Delete ? 'danger' : 'secondary'}} btn-sm mb-2" (click)="runTask(task)">{{task.title}}</button>
|
||||||
|
</app-setting-button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output, SimpleChange, SimpleChanges,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
TrackByFunction,
|
TrackByFunction,
|
||||||
ViewChild
|
ViewChild
|
||||||
@ -153,13 +153,21 @@ export class CardDetailLayoutComponent implements OnInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
ngOnChanges(): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
this.jumpBarKeysToRender = [...this.jumpBarKeys];
|
||||||
this.resizeJumpBar();
|
this.resizeJumpBar();
|
||||||
|
|
||||||
const startIndex = this.jumpbarService.getResumePosition(this.router.url);
|
const startIndex = this.jumpbarService.getResumePosition(this.router.url);
|
||||||
if (startIndex > 0) {
|
if (startIndex > 0) {
|
||||||
setTimeout(() => this.virtualScroller.scrollToIndex(startIndex, true, 0, ANIMATION_TIME_MS), 10);
|
setTimeout(() => this.virtualScroller.scrollToIndex(startIndex, true, 0, ANIMATION_TIME_MS), 10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.hasOwnProperty('isLoading')) {
|
||||||
|
const loadingChange = changes['isLoading'] as SimpleChange;
|
||||||
|
if (loadingChange.previousValue === true && loadingChange.currentValue === false) {
|
||||||
|
setTimeout(() => this.virtualScroller.scrollToIndex(0, true, 0, ANIMATION_TIME_MS), 10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,22 +73,28 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (title.length > 0 || actions.length > 0) {
|
@if (title.length > 0 || actions.length > 0) {
|
||||||
<div class="card-title-container">
|
<div class="card-title-container">
|
||||||
<div>
|
@if (showFormat) {
|
||||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
<app-series-format [format]="format"></app-series-format>
|
||||||
<app-promoted-icon [promoted]="isPromoted()"></app-promoted-icon>
|
}
|
||||||
<app-series-format [format]="format"></app-series-format>
|
|
||||||
@if (linkUrl) {
|
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
|
||||||
<a class="dark-exempt btn-icon" href="javascript:void(0);" [routerLink]="linkUrl">{{title}}</a>
|
@if (isPromoted(); as isPromoted) {
|
||||||
} @else {
|
<span class="me-1"><app-promoted-icon [promoted]="isPromoted"></app-promoted-icon></span>
|
||||||
{{title}}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
@if (actions && actions.length > 0) {
|
|
||||||
<span class="card-actions float-end">
|
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
</div>
|
@if (linkUrl) {
|
||||||
|
<a class="dark-exempt btn-icon" href="javascript:void(0);" [routerLink]="linkUrl">{{title}}</a>
|
||||||
|
} @else {
|
||||||
|
{{title}}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (actions && actions.length > 0) {
|
||||||
|
<span class="card-actions">
|
||||||
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1 +1,5 @@
|
|||||||
@use '../../../card-item-common';
|
@use '../../../card-item-common';
|
||||||
|
|
||||||
|
.card-title-container {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
@ -149,6 +149,10 @@ export class CardItemComponent implements OnInit {
|
|||||||
* A method that if defined will return the url
|
* A method that if defined will return the url
|
||||||
*/
|
*/
|
||||||
@Input() linkUrl?: string;
|
@Input() linkUrl?: string;
|
||||||
|
/**
|
||||||
|
* Show the format of the series
|
||||||
|
*/
|
||||||
|
@Input() showFormat: boolean = true;
|
||||||
/**
|
/**
|
||||||
* Event emitted when item is clicked
|
* Event emitted when item is clicked
|
||||||
*/
|
*/
|
||||||
|
@ -73,21 +73,21 @@
|
|||||||
|
|
||||||
|
|
||||||
<div class="card-title-container">
|
<div class="card-title-container">
|
||||||
<span class="card-title" id="{{chapter.id}}" tabindex="0" [ngbTooltip]="chapter.isSpecial ? (chapter.title || chapter.range) : null">
|
<span class="card-title" id="{{chapter.id}}" tabindex="0" [ngbTooltip]="chapter.isSpecial ? (chapter.title || chapter.range) : null">
|
||||||
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/chapter/{{chapter.id}}">
|
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/chapter/{{chapter.id}}">
|
||||||
@if (chapter.isSpecial) {
|
@if (chapter.isSpecial) {
|
||||||
{{chapter.title || chapter.range}}
|
{{chapter.title || chapter.range}}
|
||||||
} @else {
|
} @else {
|
||||||
<app-entity-title [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title>
|
<app-entity-title [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<span class="card-actions float-end">
|
<span class="card-actions">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -62,15 +62,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-title-container">
|
<div class="card-title-container">
|
||||||
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}">
|
<app-series-format [format]="series.format"></app-series-format>
|
||||||
<app-series-format [format]="series.format"></app-series-format>
|
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}">
|
||||||
<a class="dark-exempt btn-icon ms-1" routerLink="/library/{{libraryId}}/series/{{series.id}}">
|
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}">
|
||||||
{{series.name}}
|
{{series.name}}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if (actions && actions.length > 0) {
|
@if (actions && actions.length > 0) {
|
||||||
<span class="card-actions float-end">
|
<span class="card-actions">
|
||||||
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
|
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
@ -60,15 +60,16 @@
|
|||||||
|
|
||||||
<div class="card-title-container">
|
<div class="card-title-container">
|
||||||
<span class="card-title" id="{{volume.id}}" tabindex="0">
|
<span class="card-title" id="{{volume.id}}" tabindex="0">
|
||||||
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/volume/{{volume.id}}">
|
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/volume/{{volume.id}}">
|
||||||
{{volume.name}}
|
{{volume.name}}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@if (actions && actions.length > 0) {
|
|
||||||
<span class="card-actions float-end">
|
@if (actions && actions.length > 0) {
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables>
|
<span class="card-actions">
|
||||||
</span>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables>
|
||||||
}
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
[linkUrl]="'/collections/' + item.id"
|
[linkUrl]="'/collections/' + item.id"
|
||||||
(clicked)="loadCollection(item)"
|
(clicked)="loadCollection(item)"
|
||||||
(selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)"
|
(selection)="bulkSelectionService.handleCardSelection('collection', position, collections.length, $event)"
|
||||||
[selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true">
|
[selected]="bulkSelectionService.isCardSelected('collection', position)" [allowSelection]="true" [showFormat]="false">
|
||||||
|
|
||||||
<ng-template #subtitle>
|
<ng-template #subtitle>
|
||||||
<app-collection-owner [collection]="item"></app-collection-owner>
|
<app-collection-owner [collection]="item"></app-collection-owner>
|
||||||
|
@ -7,11 +7,12 @@
|
|||||||
{{collectionTag.title}}<span class="ms-1" *ngIf="collectionTag.promoted">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
|
{{collectionTag.title}}<span class="ms-1" *ngIf="collectionTag.promoted">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
|
||||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||||
</h4>
|
</h4>
|
||||||
|
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</app-side-nav-companion-bar>
|
</app-side-nav-companion-bar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="collectionTag !== undefined" #scrollingBlock>
|
<div class="container-fluid" *ngIf="collectionTag !== undefined">
|
||||||
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
|
@if (summary.length > 0 || collectionTag.source !== ScrobbleProvider.Kavita) {
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
||||||
@ -43,13 +44,11 @@
|
|||||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
|
|
||||||
<app-card-detail-layout *ngIf="filter"
|
<app-card-detail-layout *ngIf="filter"
|
||||||
[header]="t('series-header')"
|
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[items]="series"
|
[items]="series"
|
||||||
[pagination]="pagination"
|
[pagination]="pagination"
|
||||||
[filterSettings]="filterSettings"
|
[filterSettings]="filterSettings"
|
||||||
[filterOpen]="filterOpen"
|
[filterOpen]="filterOpen"
|
||||||
[parentScroll]="scrollingBlock"
|
|
||||||
[trackByIdentity]="trackByIdentity"
|
[trackByIdentity]="trackByIdentity"
|
||||||
[jumpBarKeys]="jumpbarKeys"
|
[jumpBarKeys]="jumpbarKeys"
|
||||||
(applyFilter)="updateFilter($event)">
|
(applyFilter)="updateFilter($event)">
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.poster {
|
.poster {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
@ -30,14 +36,6 @@
|
|||||||
max-height: calc(var(--vh)*100 - 170px);
|
max-height: calc(var(--vh)*100 - 170px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is responsible for ensuring we scroll down and only tabs and companion bar is visible
|
|
||||||
.main-container {
|
|
||||||
// Height set dynamically by get ScrollingBlockHeight()
|
|
||||||
overflow-y: auto;
|
|
||||||
position: relative;
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
@ -34,6 +34,7 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se
|
|||||||
import {ToastrService} from "ngx-toastr";
|
import {ToastrService} from "ngx-toastr";
|
||||||
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
|
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
|
||||||
import {ReaderService} from "../../_services/reader.service";
|
import {ReaderService} from "../../_services/reader.service";
|
||||||
|
import {QueryContext} from "../../_models/metadata/v2/query-context";
|
||||||
|
|
||||||
enum StreamId {
|
enum StreamId {
|
||||||
OnDeck,
|
OnDeck,
|
||||||
@ -157,7 +158,7 @@ export class DashboardComponent implements OnInit {
|
|||||||
case StreamType.SmartFilter:
|
case StreamType.SmartFilter:
|
||||||
s.api = this.filterUtilityService.decodeFilter(s.smartFilterEncoded!).pipe(
|
s.api = this.filterUtilityService.decodeFilter(s.smartFilterEncoded!).pipe(
|
||||||
switchMap(filter => {
|
switchMap(filter => {
|
||||||
return this.seriesService.getAllSeriesV2(0, 20, filter);
|
return this.seriesService.getAllSeriesV2(0, 20, filter, QueryContext.Dashboard);
|
||||||
}))
|
}))
|
||||||
.pipe(map(d => d.result),tap(() => this.increment()), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
|
.pipe(map(d => d.result),tap(() => this.increment()), takeUntilDestroyed(this.destroyRef), shareReplay({bufferSize: 1, refCount: true}));
|
||||||
break;
|
break;
|
||||||
|
@ -1721,7 +1721,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
// menu only code
|
// menu only code
|
||||||
savePref() {
|
savePref() {
|
||||||
const modelSettings = this.generalSettingsForm.value;
|
const modelSettings = this.generalSettingsForm.getRawValue();
|
||||||
// Get latest preferences from user, overwrite with what we manage in this UI, then save
|
// Get latest preferences from user, overwrite with what we manage in this UI, then save
|
||||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
@ -9,62 +9,69 @@
|
|||||||
|
|
||||||
<div class="col-md-2 me-2 col-10 mb-2">
|
<div class="col-md-2 me-2 col-10 mb-2">
|
||||||
<select class="col-auto form-select" formControlName="comparison">
|
<select class="col-auto form-select" formControlName="comparison">
|
||||||
<option *ngFor="let comparison of validComparisons$ | async" [value]="comparison">{{comparison | filterComparison}}</option>
|
@for(comparison of validComparisons$ | async; track comparison) {
|
||||||
|
<option [value]="comparison">{{comparison | filterComparison}}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4 col-10 mb-2">
|
<div class="col-md-4 col-10 mb-2">
|
||||||
|
@if (formGroup.get('comparison')?.value != FilterComparison.IsEmpty) {
|
||||||
<ng-container *ngIf="predicateType$ | async as predicateType">
|
<ng-container *ngIf="predicateType$ | async as predicateType">
|
||||||
<ng-container [ngSwitch]="predicateType">
|
<ng-container [ngSwitch]="predicateType">
|
||||||
<ng-container *ngSwitchCase="PredicateType.Text">
|
<ng-container *ngSwitchCase="PredicateType.Text">
|
||||||
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
|
<input type="text" class="form-control me-2" autocomplete="true" formControlName="filterValue">
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngSwitchCase="PredicateType.Number">
|
|
||||||
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngSwitchCase="PredicateType.Boolean">
|
|
||||||
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngSwitchCase="PredicateType.Date">
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
class="form-control"
|
|
||||||
placeholder="yyyy-mm-dd"
|
|
||||||
name="dp"
|
|
||||||
formControlName="filterValue"
|
|
||||||
(dateSelect)="onDateSelect($event)"
|
|
||||||
(blur)="updateIfDateFilled()"
|
|
||||||
ngbDatepicker
|
|
||||||
#d="ngbDatepicker"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-outline-secondary fa-solid fa-calendar-days" (click)="d.toggle()" type="button"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngSwitchCase="PredicateType.Dropdown">
|
|
||||||
<ng-container *ngIf="dropdownOptions$ | async as opts">
|
|
||||||
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
|
|
||||||
<ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
|
|
||||||
<select2 [data]="options"
|
|
||||||
formControlName="filterValue"
|
|
||||||
[hideSelectedItems]="true"
|
|
||||||
[multiple]="multipleAllowed"
|
|
||||||
[infiniteScroll]="true"
|
|
||||||
[resettable]="true">
|
|
||||||
</select2>
|
|
||||||
</ng-template>
|
|
||||||
</ng-container>
|
|
||||||
</ng-container>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-container *ngSwitchCase="PredicateType.Number">
|
||||||
|
<input type="number" inputmode="numeric" class="form-control me-2" formControlName="filterValue" min="0">
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngSwitchCase="PredicateType.Boolean">
|
||||||
|
<input type="checkbox" class="form-check-input mt-2 me-2" style="font-size: 1.5rem" formControlName="filterValue">
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngSwitchCase="PredicateType.Date">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
placeholder="yyyy-mm-dd"
|
||||||
|
name="dp"
|
||||||
|
formControlName="filterValue"
|
||||||
|
(dateSelect)="onDateSelect($event)"
|
||||||
|
(blur)="updateIfDateFilled()"
|
||||||
|
ngbDatepicker
|
||||||
|
#d="ngbDatepicker"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-outline-secondary fa-solid fa-calendar-days" (click)="d.toggle()" type="button"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngSwitchCase="PredicateType.Dropdown">
|
||||||
|
<ng-container *ngIf="dropdownOptions$ | async as opts">
|
||||||
|
<ng-container *ngTemplateOutlet="dropdown; context: { options: opts, multipleAllowed: MultipleDropdownAllowed }"></ng-container>
|
||||||
|
<ng-template #dropdown let-options="options" let-multipleAllowed="multipleAllowed">
|
||||||
|
<select2 [data]="options"
|
||||||
|
formControlName="filterValue"
|
||||||
|
[hideSelectedItems]="true"
|
||||||
|
[multiple]="multipleAllowed"
|
||||||
|
[infiniteScroll]="true"
|
||||||
|
[resettable]="true">
|
||||||
|
</select2>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col pt-2 ms-2">
|
<div class="col pt-2 ms-2">
|
||||||
<ng-container *ngIf="UiLabel !== null">
|
@if (UiLabel !== null) {
|
||||||
<span class="text-muted">{{t(UiLabel.unit)}}</span>
|
<span class="text-muted">{{t(UiLabel.unit)}}</span>
|
||||||
<i *ngIf="UiLabel.tooltip" class="fa fa-info-circle ms-1" aria-hidden="true" [ngbTooltip]="t(UiLabel.tooltip)"></i>
|
@if (UiLabel.tooltip) {
|
||||||
</ng-container>
|
<i class="fa fa-info-circle ms-1" aria-hidden="true" [ngbTooltip]="t(UiLabel.tooltip)"></i>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-content #removeBtn></ng-content>
|
<ng-content #removeBtn></ng-content>
|
||||||
|
@ -55,6 +55,7 @@ const unitLabels: Map<FilterField, FilterRowUi> = new Map([
|
|||||||
[FilterField.ReadingDate, new FilterRowUi('unit-reading-date')],
|
[FilterField.ReadingDate, new FilterRowUi('unit-reading-date')],
|
||||||
[FilterField.AverageRating, new FilterRowUi('unit-average-rating')],
|
[FilterField.AverageRating, new FilterRowUi('unit-average-rating')],
|
||||||
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
|
[FilterField.ReadProgress, new FilterRowUi('unit-reading-progress')],
|
||||||
|
[FilterField.UserRating, new FilterRowUi('unit-user-rating')],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
const StringFields = [FilterField.SeriesName, FilterField.Summary, FilterField.Path, FilterField.FilePath];
|
||||||
@ -80,6 +81,17 @@ const NumberFieldsThatIncludeDateComparisons = [
|
|||||||
FilterField.ReleaseYear
|
FilterField.ReleaseYear
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FieldsThatShouldIncludeIsEmpty = [
|
||||||
|
FilterField.Summary, FilterField.UserRating, FilterField.Genres,
|
||||||
|
FilterField.CollectionTags, FilterField.Tags, FilterField.ReleaseYear,
|
||||||
|
FilterField.Translators, FilterField.Characters, FilterField.Publisher,
|
||||||
|
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
|
||||||
|
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
|
||||||
|
FilterField.Writers, FilterField.Imprint, FilterField.Team,
|
||||||
|
FilterField.Location,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
const StringComparisons = [
|
const StringComparisons = [
|
||||||
FilterComparison.Equal,
|
FilterComparison.Equal,
|
||||||
FilterComparison.NotEqual,
|
FilterComparison.NotEqual,
|
||||||
@ -221,7 +233,10 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
stmt.value = stmt.value + '';
|
stmt.value = stmt.value + '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return;
|
if (stmt.comparison !== FilterComparison.IsEmpty) {
|
||||||
|
if (!stmt.value && (![FilterField.SeriesName, FilterField.Summary].includes(stmt.field) && !BooleanFields.includes(stmt.field))) return;
|
||||||
|
}
|
||||||
|
|
||||||
this.filterStatement.emit(stmt);
|
this.filterStatement.emit(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,8 +332,15 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
handleFieldChange(val: string) {
|
handleFieldChange(val: string) {
|
||||||
const inputVal = parseInt(val, 10) as FilterField;
|
const inputVal = parseInt(val, 10) as FilterField;
|
||||||
|
|
||||||
|
|
||||||
if (StringFields.includes(inputVal)) {
|
if (StringFields.includes(inputVal)) {
|
||||||
this.validComparisons$.next(StringComparisons);
|
const comps = [...StringComparisons];
|
||||||
|
|
||||||
|
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
|
||||||
|
comps.push(FilterComparison.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validComparisons$.next(comps);
|
||||||
this.predicateType$.next(PredicateType.Text);
|
this.predicateType$.next(PredicateType.Text);
|
||||||
|
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
@ -330,9 +352,14 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
|
|
||||||
if (NumberFields.includes(inputVal)) {
|
if (NumberFields.includes(inputVal)) {
|
||||||
const comps = [...NumberComparisons];
|
const comps = [...NumberComparisons];
|
||||||
|
|
||||||
if (NumberFieldsThatIncludeDateComparisons.includes(inputVal)) {
|
if (NumberFieldsThatIncludeDateComparisons.includes(inputVal)) {
|
||||||
comps.push(...DateComparisons);
|
comps.push(...DateComparisons);
|
||||||
}
|
}
|
||||||
|
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
|
||||||
|
comps.push(FilterComparison.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
this.validComparisons$.next(comps);
|
this.validComparisons$.next(comps);
|
||||||
this.predicateType$.next(PredicateType.Number);
|
this.predicateType$.next(PredicateType.Number);
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
@ -343,7 +370,12 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (DateFields.includes(inputVal)) {
|
if (DateFields.includes(inputVal)) {
|
||||||
this.validComparisons$.next(DateComparisons);
|
const comps = [...DateComparisons];
|
||||||
|
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
|
||||||
|
comps.push(FilterComparison.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validComparisons$.next(comps);
|
||||||
this.predicateType$.next(PredicateType.Date);
|
this.predicateType$.next(PredicateType.Date);
|
||||||
|
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
@ -354,7 +386,12 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (BooleanFields.includes(inputVal)) {
|
if (BooleanFields.includes(inputVal)) {
|
||||||
this.validComparisons$.next(BooleanComparisons);
|
const comps = [...DateComparisons];
|
||||||
|
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
|
||||||
|
comps.push(FilterComparison.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validComparisons$.next(comps);
|
||||||
this.predicateType$.next(PredicateType.Boolean);
|
this.predicateType$.next(PredicateType.Boolean);
|
||||||
|
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
@ -372,6 +409,10 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
if (DropdownFieldsWithoutMustContains.includes(inputVal)) {
|
if (DropdownFieldsWithoutMustContains.includes(inputVal)) {
|
||||||
comps = comps.filter(c => c !== FilterComparison.MustContains);
|
comps = comps.filter(c => c !== FilterComparison.MustContains);
|
||||||
}
|
}
|
||||||
|
if (FieldsThatShouldIncludeIsEmpty.includes(inputVal)) {
|
||||||
|
comps.push(FilterComparison.IsEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
this.validComparisons$.next(comps);
|
this.validComparisons$.next(comps);
|
||||||
this.predicateType$.next(PredicateType.Dropdown);
|
this.predicateType$.next(PredicateType.Dropdown);
|
||||||
if (this.loaded) {
|
if (this.loaded) {
|
||||||
@ -391,4 +432,5 @@ export class MetadataFilterRowComponent implements OnInit {
|
|||||||
this.propagateFilterUpdate();
|
this.propagateFilterUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly FilterComparison = FilterComparison;
|
||||||
}
|
}
|
||||||
|
@ -117,7 +117,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-close {
|
.btn-close {
|
||||||
top: 10px;
|
top: 35px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.content-container {
|
.content-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@ -24,8 +31,8 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: calc((var(--vh) *100) - 173px);
|
height: calc((var(--vh) *100) - 173px);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
&.empty {
|
&.empty {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
[linkUrl]="'/lists/' + item.id"
|
[linkUrl]="'/lists/' + item.id"
|
||||||
(clicked)="handleClick(item)"
|
(clicked)="handleClick(item)"
|
||||||
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
|
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
|
||||||
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"></app-card-item>
|
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true" [showFormat]="false"></app-card-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<ng-template #noData>
|
<ng-template #noData>
|
||||||
|
@ -0,0 +1,4 @@
|
|||||||
|
.main-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
@ -2,9 +2,9 @@
|
|||||||
<app-splash-container>
|
<app-splash-container>
|
||||||
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
||||||
<ng-container body>
|
<ng-container body>
|
||||||
<p *ngIf="!confirmed; else confirmedMessage">{{t('non-confirm-description')}}</p>
|
@if (!confirmed) {
|
||||||
|
{{t('non-confirm-description')}}
|
||||||
<ng-template #confirmedMessage>
|
} @else {
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
@ -13,7 +13,7 @@
|
|||||||
<p>{{t('confirm-description')}}</p>
|
<p>{{t('confirm-description')}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</app-splash-container>
|
</app-splash-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -4,7 +4,6 @@ import { ToastrService } from 'ngx-toastr';
|
|||||||
import { AccountService } from 'src/app/_services/account.service';
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
import { NavService } from 'src/app/_services/nav.service';
|
import { NavService } from 'src/app/_services/nav.service';
|
||||||
import { ThemeService } from 'src/app/_services/theme.service';
|
import { ThemeService } from 'src/app/_services/theme.service';
|
||||||
import { NgIf } from '@angular/common';
|
|
||||||
import { SplashContainerComponent } from '../splash-container/splash-container.component';
|
import { SplashContainerComponent } from '../splash-container/splash-container.component';
|
||||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||||
|
|
||||||
@ -17,7 +16,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco";
|
|||||||
styleUrls: ['./confirm-email-change.component.scss'],
|
styleUrls: ['./confirm-email-change.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SplashContainerComponent, NgIf, TranslocoDirective]
|
imports: [SplashContainerComponent, TranslocoDirective]
|
||||||
})
|
})
|
||||||
export class ConfirmEmailChangeComponent implements OnInit {
|
export class ConfirmEmailChangeComponent implements OnInit {
|
||||||
|
|
||||||
|
@ -3,37 +3,48 @@
|
|||||||
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
||||||
<ng-container body>
|
<ng-container body>
|
||||||
<p>{{t('description')}}</p>
|
<p>{{t('description')}}</p>
|
||||||
<div class="text-danger" *ngIf="errors.length > 0">
|
@if (errors.length > 0) {
|
||||||
<p>{{t('error-label')}}</p>
|
<div class="text-danger">
|
||||||
<ul>
|
<p>{{t('error-label')}}</p>
|
||||||
<li *ngFor="let error of errors">{{error}}</li>
|
<div class="mb-2">
|
||||||
</ul>
|
@for (error of errors; track error) {
|
||||||
</div>
|
<div>{{error}}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">{{t('username-label')}}</label>
|
<label for="username" class="form-label">{{t('username-label')}}</label>
|
||||||
<input id="username" class="form-control" formControlName="username" type="text"
|
<input id="username" class="form-control" formControlName="username" type="text"
|
||||||
aria-describeby="inviteForm-username-validations"
|
aria-describeby="inviteForm-username-validations"
|
||||||
[class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched">
|
[class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched">
|
||||||
<div id="inviteForm-username-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
@if (registerForm.dirty || registerForm.touched) {
|
||||||
<div *ngIf="registerForm.get('username')?.errors?.required">
|
<div id="inviteForm-username-validations" class="invalid-feedback">
|
||||||
{{t('required-field')}}
|
@if (registerForm.get('username')?.errors?.required) {
|
||||||
|
<div>{{t('required-field')}}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3" style="width:100%">
|
<div class="mb-3" style="width:100%">
|
||||||
<label for="email" class="form-label">{{t('email-label')}}</label>
|
<label for="email" class="form-label">{{t('email-label')}}</label>
|
||||||
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required readonly
|
<input class="form-control" type="email" inputmode="email" id="email" formControlName="email" required readonly
|
||||||
[class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
|
[class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
|
||||||
<div id="inviteForm-email-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
|
||||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
@if (registerForm.dirty || registerForm.touched) {
|
||||||
{{t('required-field')}}
|
<div id="inviteForm-email-validations" class="invalid-feedback">
|
||||||
|
@if (registerForm.get('email')?.errors?.required) {
|
||||||
|
<div>{{t('required-field')}}</div>
|
||||||
|
}
|
||||||
|
@if (registerForm.get('email')?.errors?.email) {
|
||||||
|
<div>{{t('valid-email')}}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="registerForm.get('email')?.errors?.email">
|
}
|
||||||
{{t('valid-email')}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@ -43,14 +54,17 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<span class="visually-hidden" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
|
<span class="visually-hidden" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
|
||||||
<input id="password" class="form-control" maxlength="256" minlength="6" pattern="^.{6,256}$" formControlName="password" type="password" aria-describedby="password-help">
|
<input id="password" class="form-control" maxlength="256" minlength="6" pattern="^.{6,256}$" formControlName="password" type="password" aria-describedby="password-help">
|
||||||
<div id="inviteForm-password-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
|
||||||
<div *ngIf="registerForm.get('password')?.errors?.required">
|
@if (registerForm.dirty || registerForm.touched) {
|
||||||
{{t('required-field')}}
|
<div id="inviteForm-password-validations" class="invalid-feedback">
|
||||||
|
@if (registerForm.get('password')?.errors?.required) {
|
||||||
|
<div>{{t('required-field')}}</div>
|
||||||
|
}
|
||||||
|
@if (registerForm.get('password')?.errors?.minlength || registerForm.get('password')?.errors?.maxLength || registerForm.get('password')?.errors?.pattern) {
|
||||||
|
<div>{{t('password-validation')}}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="registerForm.get('password')?.errors?.minlength || registerForm.get('password')?.errors?.maxLength || registerForm.get('password')?.errors?.pattern">
|
}
|
||||||
{{t('password-validation')}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="float-end">
|
<div class="float-end">
|
||||||
|
@ -6,7 +6,7 @@ import { ThemeService } from 'src/app/_services/theme.service';
|
|||||||
import { AccountService } from 'src/app/_services/account.service';
|
import { AccountService } from 'src/app/_services/account.service';
|
||||||
import { NavService } from 'src/app/_services/nav.service';
|
import { NavService } from 'src/app/_services/nav.service';
|
||||||
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { NgIf, NgFor, NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { SplashContainerComponent } from '../splash-container/splash-container.component';
|
import { SplashContainerComponent } from '../splash-container/splash-container.component';
|
||||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {take} from "rxjs/operators";
|
import {take} from "rxjs/operators";
|
||||||
@ -17,7 +17,7 @@ import {take} from "rxjs/operators";
|
|||||||
styleUrls: ['./confirm-email.component.scss'],
|
styleUrls: ['./confirm-email.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SplashContainerComponent, NgIf, NgFor, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoDirective]
|
imports: [SplashContainerComponent, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoDirective]
|
||||||
})
|
})
|
||||||
export class ConfirmEmailComponent implements OnDestroy {
|
export class ConfirmEmailComponent implements OnDestroy {
|
||||||
/**
|
/**
|
||||||
|
@ -96,7 +96,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="col-auto ms-2 d-none d-md-block">
|
<div class="col-auto ms-2 d-none d-md-block">
|
||||||
<div class="card-actions" [ngbTooltip]="t('more-alt')">
|
<div class="card-actions mt-2" [ngbTooltip]="t('more-alt')">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary-outline btn"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary-outline btn"></app-card-actionables>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
left: 20px;
|
left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-container{
|
.card-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, 160px);
|
grid-template-columns: repeat(auto-fill, 160px);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
@if (format !== MangaFormat.UNKNOWN) {
|
@if (format !== MangaFormat.UNKNOWN) {
|
||||||
@if (useTitle) {
|
@if (useTitle) {
|
||||||
<i class="{{format | mangaFormatIcon}} me-1" aria-hidden="true" title="{{format | mangaFormat}}"></i>
|
<i class="{{format | mangaFormatIcon}}" aria-hidden="true" title="{{format | mangaFormat}}"></i>
|
||||||
} @else {
|
} @else {
|
||||||
<i class="{{format | mangaFormatIcon}} me-1" aria-hidden="true" ngbTooltip="{{format | mangaFormat}}"></i>
|
<i class="{{format | mangaFormatIcon}}" aria-hidden="true" ngbTooltip="{{format | mangaFormat}}"></i>
|
||||||
}
|
}
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
i {
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
@ -13,7 +13,7 @@
|
|||||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
|
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="resetFilter()">{{t('clear')}}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 ms-2">
|
<div class="col-auto ms-2 float-end">
|
||||||
<button class="btn btn-primary" style="margin-top: 30px" (click)="addNewExternalSource()">{{t('add-source')}}</button>
|
<button class="btn btn-primary" style="margin-top: 30px" (click)="addNewExternalSource()">{{t('add-source')}}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
<i class="fa-solid fa-triangle-exclamation red me-2" [ngbTooltip]="t('errored')"></i>
|
<i class="fa-solid fa-triangle-exclamation red me-2" [ngbTooltip]="t('errored')"></i>
|
||||||
<span class="visually-hidden">{{t('errored')}}</span>
|
<span class="visually-hidden">{{t('errored')}}</span>
|
||||||
}
|
}
|
||||||
<a [href]="'all-series?' + f.filter" target="_blank">{{f.name}}</a>
|
<a [href]="'/all-series?' + f.filter" target="_blank">{{f.name}}</a>
|
||||||
</span>
|
</span>
|
||||||
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
<button class="btn btn-danger float-end" (click)="deleteFilter(f)">
|
||||||
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
<i class="fa-solid fa-trash" aria-hidden="true"></i>
|
||||||
|
@ -35,7 +35,7 @@ export class UserStatsInfoCardsComponent {
|
|||||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||||
ref.componentInstance.items = yearCounts.map(t => {
|
ref.componentInstance.items = yearCounts.map(t => {
|
||||||
const countStr = translate('user-stats-info-cards.pages-count', {num: numberPipe.transform(t.value)});
|
const countStr = translate('user-stats-info-cards.pages-count', {num: numberPipe.transform(t.value)});
|
||||||
return `${t.name}: ${countStr})`;
|
return `${t.name}: ${countStr}s`;
|
||||||
});
|
});
|
||||||
ref.componentInstance.title = translate('user-stats-info-cards.pages-read-by-year-title');
|
ref.componentInstance.title = translate('user-stats-info-cards.pages-read-by-year-title');
|
||||||
});
|
});
|
||||||
@ -47,7 +47,7 @@ export class UserStatsInfoCardsComponent {
|
|||||||
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
const ref = this.modalService.open(GenericListModalComponent, { scrollable: true });
|
||||||
ref.componentInstance.items = yearCounts.map(t => {
|
ref.componentInstance.items = yearCounts.map(t => {
|
||||||
const countStr = translate('user-stats-info-cards.words-count', {num: numberPipe.transform(t.value)});
|
const countStr = translate('user-stats-info-cards.words-count', {num: numberPipe.transform(t.value)});
|
||||||
return `${t.name}: ${countStr})`;
|
return `${t.name}: ${countStr}`;
|
||||||
});
|
});
|
||||||
ref.componentInstance.title = translate('user-stats-info-cards.words-read-by-year-title');
|
ref.componentInstance.title = translate('user-stats-info-cards.words-read-by-year-title');
|
||||||
});
|
});
|
||||||
|
@ -353,7 +353,7 @@
|
|||||||
<div class="col-10">
|
<div class="col-10">
|
||||||
<input type="range" class="form-range" id="fontsize" min="50" max="300" step="10"
|
<input type="range" class="form-range" id="fontsize" min="50" max="300" step="10"
|
||||||
formControlName="bookReaderFontSize">
|
formControlName="bookReaderFontSize">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<span class="ps-2 col-2 align-middle">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
|
<span class="ps-2 col-2 align-middle">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<ng-container *transloco="let t; read:'theme-manager'">
|
<ng-container *transloco="let t; read:'theme-manager'">
|
||||||
|
|
||||||
<div class="position-relative">
|
<div class="position-relative">
|
||||||
@if (selectedTheme !== undefined && (hasAdmin$ | async)) {
|
@if ((hasAdmin$ | async)) {
|
||||||
<button class="btn btn-primary-outline position-absolute custom-position" (click)="selectTheme(undefined)" [title]="t('add')">
|
<button class="btn btn-primary-outline position-absolute custom-position" (click)="selectTheme(undefined)" [title]="t('add')">
|
||||||
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
|
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -16,12 +16,20 @@
|
|||||||
<div class="pe-2">
|
<div class="pe-2">
|
||||||
<ul style="height: 100%" class="list-group list-group-flush">
|
<ul style="height: 100%" class="list-group list-group-flush">
|
||||||
|
|
||||||
@for (theme of themeService.themes$ | async; track theme.name) {
|
<li class="list-group-item d-flex justify-content-between align-items-start" style="height: 40px" aria-hidden="true">
|
||||||
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
|
<div class="fw-bold section-header">{{t('downloaded')}}</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
@for (theme of downloadedThemes; track theme.name) {
|
||||||
|
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme, downloaded: true}"></ng-container>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-start" style="height: 40px" aria-hidden="true">
|
||||||
|
<div class="fw-bold section-header">{{t('downloadable')}}</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
@for (theme of downloadableThemes; track theme.name) {
|
@for (theme of downloadableThemes; track theme.name) {
|
||||||
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
|
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme, downloaded: false}"></ng-container>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -114,25 +122,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<ng-template #themeOption let-item>
|
<ng-template #themeOption let-item let-downloaded="downloaded">
|
||||||
@if (item !== undefined) {
|
@if (item !== undefined) {
|
||||||
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-start {{selectedTheme && selectedTheme.name === item.name ? 'active' : ''}}" (click)="selectTheme(item)">
|
<li class="list-group-item d-flex justify-content-between align-items-start {{selectedTheme && selectedTheme.name === item.name ? 'active' : ''}}" (click)="selectTheme(item)">
|
||||||
<div class="ms-2 me-auto">
|
<div class="ms-2 me-auto">
|
||||||
<div class="fw-bold">{{item.name | sentenceCase}}</div>
|
<div class="fw-bold">{{item.name | sentenceCase}}</div>
|
||||||
|
|
||||||
@if (item.hasOwnProperty('provider')) {
|
@if (item.provider !== ThemeProvider.System && item.compatibleVersion) {
|
||||||
<span class="pill p-1 me-1 provider">{{item.provider | siteThemeProvider}}</span>
|
<span class="pill p-1 me-1 version">v{{item.compatibleVersion}}</span>
|
||||||
} @else if (item.hasOwnProperty('lastCompatibleVersion')) {
|
} @else if (item.hasOwnProperty('lastCompatibleVersion')) {
|
||||||
<span class="pill p-1 me-1 provider">{{ThemeProvider.Custom | siteThemeProvider}}</span><span class="pill p-1 me-1 version">v{{item.lastCompatibleVersion}}</span>
|
<span class="pill p-1 me-1 version">v{{item.lastCompatibleVersion}}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (currentTheme && item.name === currentTheme.name) {
|
@if (currentTheme && item.name === currentTheme.name) {
|
||||||
<span class="pill p-1 active">{{t('active-theme')}}</span>
|
<span class="pill p-1 active">{{t('active-theme')}}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (item.hasOwnProperty('isDefault') && item.isDefault) {
|
||||||
|
<span class="pill p-1 ms-1">{{t('default-theme')}}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (item.hasOwnProperty('isDefault') && item.isDefault) {
|
|
||||||
<i class="fa-solid fa-star" [attr.aria-label]="t('default-theme')"></i>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -37,11 +37,15 @@
|
|||||||
background-color: var(--card-bg-color);
|
background-color: var(--card-bg-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.list-group-item, .list-group-item.active {
|
.list-group-item, .list-group-item.active {
|
||||||
border-top-width: 0;
|
border-top-width: 0;
|
||||||
border-bottom-width: 0;
|
border-bottom-width: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
@ -68,3 +72,7 @@ ngx-file-drop ::ng-deep > div {
|
|||||||
right: 15px;
|
right: 15px;
|
||||||
top: -42px;
|
top: -42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { ToastrService } from 'ngx-toastr';
|
import { ToastrService } from 'ngx-toastr';
|
||||||
import {distinctUntilChanged, map, take} from 'rxjs';
|
import {distinctUntilChanged, map, take, tap} from 'rxjs';
|
||||||
import { ThemeService } from 'src/app/_services/theme.service';
|
import { ThemeService } from 'src/app/_services/theme.service';
|
||||||
import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
||||||
import { User } from 'src/app/_models/user';
|
import { User } from 'src/app/_models/user';
|
||||||
@ -65,9 +65,11 @@ export class ThemeManagerComponent {
|
|||||||
user: User | undefined;
|
user: User | undefined;
|
||||||
selectedTheme: ThemeContainer | undefined;
|
selectedTheme: ThemeContainer | undefined;
|
||||||
downloadableThemes: Array<DownloadableSiteTheme> = [];
|
downloadableThemes: Array<DownloadableSiteTheme> = [];
|
||||||
|
downloadedThemes: Array<SiteTheme> = [];
|
||||||
hasAdmin$ = this.accountService.currentUser$.pipe(
|
hasAdmin$ = this.accountService.currentUser$.pipe(
|
||||||
takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
map(c => c && this.accountService.hasAdminRole(c))
|
map(c => c && this.accountService.hasAdminRole(c)),
|
||||||
|
shareReplay({refCount: true, bufferSize: 1}),
|
||||||
);
|
);
|
||||||
|
|
||||||
files: NgxFileDropEntry[] = [];
|
files: NgxFileDropEntry[] = [];
|
||||||
@ -78,6 +80,11 @@ export class ThemeManagerComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
||||||
|
this.themeService.themes$.pipe(tap(themes => {
|
||||||
|
this.downloadedThemes = themes;
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
})).subscribe();
|
||||||
|
|
||||||
this.loadDownloadableThemes();
|
this.loadDownloadableThemes();
|
||||||
|
|
||||||
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
|
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
|
||||||
@ -118,7 +125,6 @@ export class ThemeManagerComponent {
|
|||||||
pref.theme = theme;
|
pref.theme = theme;
|
||||||
this.accountService.updatePreferences(pref).subscribe();
|
this.accountService.updatePreferences(pref).subscribe();
|
||||||
// Updating theme emits the new theme to load on the themes$
|
// Updating theme emits the new theme to load on the themes$
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,8 +158,15 @@ export class ThemeManagerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadTheme(theme: DownloadableSiteTheme) {
|
downloadTheme(theme: DownloadableSiteTheme) {
|
||||||
this.themeService.downloadTheme(theme).subscribe(theme => {
|
this.themeService.downloadTheme(theme).subscribe(downloadedTheme => {
|
||||||
this.removeDownloadedTheme(theme);
|
this.removeDownloadedTheme(downloadedTheme);
|
||||||
|
this.themeService.getThemes().subscribe(themes => {
|
||||||
|
this.downloadedThemes = themes;
|
||||||
|
const oldTheme = this.downloadedThemes.filter(d => d.name === theme.name)[0];
|
||||||
|
this.selectTheme(oldTheme);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +76,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="col-auto ms-2 d-none d-md-block">
|
<div class="col-auto ms-2 d-none d-md-block">
|
||||||
<div class="card-actions" [ngbTooltip]="t('more-alt')">
|
<div class="card-actions mt-2" [ngbTooltip]="t('more-alt')">
|
||||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="volumeActions" [labelBy]="series.name + ' ' + volume.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-secondary-outline btn"></app-card-actionables>
|
<app-card-actionables (actionHandler)="performAction($event)" [actions]="volumeActions" [labelBy]="series.name + ' ' + volume.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-secondary-outline btn"></app-card-actionables>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,47 +1,49 @@
|
|||||||
<ng-container *transloco="let t; read:'want-to-read'">
|
<div class="main-container container-fluid">
|
||||||
<div #companionBar>
|
<ng-container *transloco="let t; read:'want-to-read'">
|
||||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
<div #companionBar>
|
||||||
<ng-container title>
|
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||||
<h4>
|
<ng-container title>
|
||||||
{{t('title')}}
|
<h4>
|
||||||
</h4>
|
{{t('title')}}
|
||||||
</ng-container>
|
</h4>
|
||||||
<h5 subtitle>{{t('series-count', {num: (pagination.totalItems | number)})}}</h5>
|
</ng-container>
|
||||||
</app-side-nav-companion-bar>
|
<h5 subtitle>{{t('series-count', {num: (pagination.totalItems | number)})}}</h5>
|
||||||
</div>
|
</app-side-nav-companion-bar>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid ps-0" #scrollingBlock>
|
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid ps-0" #scrollingBlock>
|
||||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
|
|
||||||
<app-card-detail-layout *ngIf="filter"
|
<app-card-detail-layout *ngIf="filter"
|
||||||
[isLoading]="isLoading"
|
[isLoading]="isLoading"
|
||||||
[items]="series"
|
[items]="series"
|
||||||
[pagination]="pagination"
|
[pagination]="pagination"
|
||||||
[filterSettings]="filterSettings"
|
[filterSettings]="filterSettings"
|
||||||
[filterOpen]="filterOpen"
|
[filterOpen]="filterOpen"
|
||||||
[jumpBarKeys]="jumpbarKeys"
|
[jumpBarKeys]="jumpbarKeys"
|
||||||
[trackByIdentity]="trackByIdentity"
|
[trackByIdentity]="trackByIdentity"
|
||||||
[refresh]="refresh"
|
[refresh]="refresh"
|
||||||
(applyFilter)="updateFilter($event)">
|
(applyFilter)="updateFilter($event)">
|
||||||
<ng-template #cardItem let-item let-position="idx">
|
<ng-template #cardItem let-item let-position="idx">
|
||||||
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="removeSeries($event)"
|
<app-series-card [series]="item" [libraryId]="item.libraryId" (reload)="removeSeries($event)"
|
||||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"
|
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"
|
||||||
></app-series-card>
|
></app-series-card>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<div *ngIf="!filterActive && series.length === 0">
|
<div *ngIf="!filterActive && series.length === 0">
|
||||||
<ng-template #noData>
|
<ng-template #noData>
|
||||||
{{t('no-items')}}
|
{{t('no-items')}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="filterActive && series.length === 0">
|
<div *ngIf="filterActive && series.length === 0">
|
||||||
<ng-template #noData>
|
<ng-template #noData>
|
||||||
{{t('no-items-filtered')}}
|
{{t('no-items-filtered')}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</app-card-detail-layout>
|
</app-card-detail-layout>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
@ -1,48 +1,4 @@
|
|||||||
.virtual-scroller, virtual-scroller {
|
.main-container {
|
||||||
width: 100%;
|
margin-top: 10px;
|
||||||
height: calc(100vh - 85px);
|
padding: 0 0 0 10px;
|
||||||
max-height: calc(var(--vh)*100 - 170px);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// This is responsible for ensuring we scroll down and only tabs and companion bar is visible
|
|
||||||
.main-container {
|
|
||||||
// Height set dynamically by get ScrollingBlockHeight()
|
|
||||||
overflow: auto;
|
|
||||||
position: relative;
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
|
|
||||||
// For firefox
|
|
||||||
@supports (-moz-appearance:none) {
|
|
||||||
scrollbar-color: transparent transparent;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
background-color: transparent; /*make scrollbar space invisible */
|
|
||||||
width: inherit;
|
|
||||||
display: none;
|
|
||||||
visibility: hidden;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: transparent; /*makes it invisible when not hovering*/
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
// For firefox
|
|
||||||
@supports (-moz-appearance:none) {
|
|
||||||
scrollbar-color: rgba(255,255,255,0.3) rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
visibility: visible;
|
|
||||||
background-color: rgba(255,255,255,0.3); /*On hover, it will turn grey*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -27,7 +27,8 @@
|
|||||||
"not-valid-email": "{{validation.valid-email}}",
|
"not-valid-email": "{{validation.valid-email}}",
|
||||||
"cancel": "{{common.cancel}}",
|
"cancel": "{{common.cancel}}",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"update": "Update"
|
"update": "Update",
|
||||||
|
"account-detail-title": "Account Details"
|
||||||
},
|
},
|
||||||
|
|
||||||
"user-scrobble-history": {
|
"user-scrobble-history": {
|
||||||
@ -199,8 +200,10 @@
|
|||||||
"description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.",
|
"description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.",
|
||||||
"site-themes": "Site Themes",
|
"site-themes": "Site Themes",
|
||||||
"set-default": "Set Default",
|
"set-default": "Set Default",
|
||||||
"default-theme": "Default theme",
|
"default-theme": "Default",
|
||||||
"download": "{{changelog.download}}",
|
"download": "{{changelog.download}}",
|
||||||
|
"downloaded": "Downloaded",
|
||||||
|
"downloadable": "Downloadable",
|
||||||
"apply": "{{common.apply}}",
|
"apply": "{{common.apply}}",
|
||||||
"applied": "Applied",
|
"applied": "Applied",
|
||||||
"active-theme": "Active",
|
"active-theme": "Active",
|
||||||
@ -258,7 +261,7 @@
|
|||||||
"title": "Edit Device",
|
"title": "Edit Device",
|
||||||
"device-name-label": "{{manage-devices.name-label}}",
|
"device-name-label": "{{manage-devices.name-label}}",
|
||||||
"platform-label": "{{manage-devices.platform-label}}",
|
"platform-label": "{{manage-devices.platform-label}}",
|
||||||
|
|
||||||
"email-label": "{{common.email}}",
|
"email-label": "{{common.email}}",
|
||||||
"email-tooltip": "This email will be used to accept the file via Send To",
|
"email-tooltip": "This email will be used to accept the file via Send To",
|
||||||
"device-platform-label": "Device Platform",
|
"device-platform-label": "Device Platform",
|
||||||
@ -731,7 +734,6 @@
|
|||||||
"confirm-email": {
|
"confirm-email": {
|
||||||
"title": "Register",
|
"title": "Register",
|
||||||
"description": "Complete the form to complete your registration",
|
"description": "Complete the form to complete your registration",
|
||||||
"error-label": "Errors: ",
|
|
||||||
"username-label": "{{common.username}}",
|
"username-label": "{{common.username}}",
|
||||||
"password-label": "{{common.password}}",
|
"password-label": "{{common.password}}",
|
||||||
"email-label": "{{common.email}}",
|
"email-label": "{{common.email}}",
|
||||||
@ -853,7 +855,8 @@
|
|||||||
"teams-title": "Teams",
|
"teams-title": "Teams",
|
||||||
"locations-title": "Locations",
|
"locations-title": "Locations",
|
||||||
"language-title": "Language",
|
"language-title": "Language",
|
||||||
"age-rating-title": "Age Rating"
|
"age-rating-title": "Age Rating",
|
||||||
|
"links-title": "Weblinks"
|
||||||
},
|
},
|
||||||
|
|
||||||
"download-button": {
|
"download-button": {
|
||||||
@ -1356,6 +1359,10 @@
|
|||||||
"analyze-files-task-desc": "Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release. Not needed if you installed post v0.7.",
|
"analyze-files-task-desc": "Runs a long-running task which will analyze files to generate extension and size. This should only be ran once for the v0.7 release. Not needed if you installed post v0.7.",
|
||||||
"analyze-files-task-success": "File analysis has been queued",
|
"analyze-files-task-success": "File analysis has been queued",
|
||||||
|
|
||||||
|
"sync-themes-task": "Sync Themes",
|
||||||
|
"sync-themes-task-desc": "Synchronize downloaded themes with upstream changes if version matches.",
|
||||||
|
"sync-themes-success": "Synchronization of themes has been queued",
|
||||||
|
|
||||||
"check-for-updates-task": "Check for Updates",
|
"check-for-updates-task": "Check for Updates",
|
||||||
"check-for-updates-task-desc": "See if there are any Stable releases ahead of your version."
|
"check-for-updates-task-desc": "See if there are any Stable releases ahead of your version."
|
||||||
},
|
},
|
||||||
@ -1463,7 +1470,8 @@
|
|||||||
"title-alt": "Kavita - {{collectionName}} Collection",
|
"title-alt": "Kavita - {{collectionName}} Collection",
|
||||||
"series-header": "Series",
|
"series-header": "Series",
|
||||||
"sync-progress": "Series Collected: {{title}}",
|
"sync-progress": "Series Collected: {{title}}",
|
||||||
"last-sync": "Last Sync: {{date}}"
|
"last-sync": "Last Sync: {{date}}",
|
||||||
|
"item-count": "{{common.item-count}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"all-collections": {
|
"all-collections": {
|
||||||
@ -1783,8 +1791,9 @@
|
|||||||
|
|
||||||
"metadata-filter-row": {
|
"metadata-filter-row": {
|
||||||
"unit-reading-date": "Date",
|
"unit-reading-date": "Date",
|
||||||
"unit-average-rating": "Average Rating (Kavita+) - only for cached series",
|
"unit-average-rating": "Kavita+ external rating, percent",
|
||||||
"unit-reading-progress": "Percent"
|
"unit-reading-progress": "Percent",
|
||||||
|
"unit-user-rating": "{{metadata-filter-row.unit-reading-progress}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"sort-field-pipe": {
|
"sort-field-pipe": {
|
||||||
@ -1946,7 +1955,7 @@
|
|||||||
"pages-count": "{{edit-chapter-modal.pages-count}}",
|
"pages-count": "{{edit-chapter-modal.pages-count}}",
|
||||||
"words-count": "{{edit-chapter-modal.words-count}}",
|
"words-count": "{{edit-chapter-modal.words-count}}",
|
||||||
"reading-time-label": "{{edit-chapter-modal.reading-time-label}}",
|
"reading-time-label": "{{edit-chapter-modal.reading-time-label}}",
|
||||||
"date-added-label": "{{edit-chapter-modal.date-added-title}}",
|
"date-added-label": "{{edit-chapter-modal.date-added-label}}",
|
||||||
"size-label": "{{edit-chapter-modal.size-label}}",
|
"size-label": "{{edit-chapter-modal.size-label}}",
|
||||||
"id-label": "{{edit-chapter-modal.id-label}}",
|
"id-label": "{{edit-chapter-modal.id-label}}",
|
||||||
"links-label": "{{series-metadata-detail.links-label}}",
|
"links-label": "{{series-metadata-detail.links-label}}",
|
||||||
@ -2223,7 +2232,8 @@
|
|||||||
"is-after": "Is after",
|
"is-after": "Is after",
|
||||||
"is-in-last": "Is in last",
|
"is-in-last": "Is in last",
|
||||||
"is-not-in-last": "Is not in last",
|
"is-not-in-last": "Is not in last",
|
||||||
"must-contains": "Must Contains"
|
"must-contains": "Must Contains",
|
||||||
|
"is-empty": "Is Empty"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@ -2450,7 +2460,7 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"required-field": "This field is required",
|
"required-field": "This field is required",
|
||||||
"valid-email": "This must be a valid email",
|
"valid-email": "This must be a valid email",
|
||||||
"password-validation": "Password must be between 6 and 32 characters in length",
|
"password-validation": "Password must be between 6 and 256 characters in length",
|
||||||
"year-validation": "This must be a valid year greater than 1000 and 4 characters long"
|
"year-validation": "This must be a valid year greater than 1000 and 4 characters long"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
276
openapi.json
276
openapi.json
@ -2,7 +2,7 @@
|
|||||||
"openapi": "3.0.1",
|
"openapi": "3.0.1",
|
||||||
"info": {
|
"info": {
|
||||||
"title": "Kavita",
|
"title": "Kavita",
|
||||||
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.6",
|
"description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.2.9",
|
||||||
"license": {
|
"license": {
|
||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
@ -1114,139 +1114,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/Cbl/validate": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Cbl"
|
|
||||||
],
|
|
||||||
"summary": "The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.\r\nIf this returns errors, the cbl will always be rejected by Kavita.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "comicVineMatching",
|
|
||||||
"in": "query",
|
|
||||||
"description": "Use comic vine matching or not. Defaults to false",
|
|
||||||
"schema": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"description": "FormBody with parameter name of cbl",
|
|
||||||
"content": {
|
|
||||||
"multipart/form-data": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cbl": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "binary"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"encoding": {
|
|
||||||
"cbl": {
|
|
||||||
"style": "form"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"text/plain": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CblImportSummaryDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CblImportSummaryDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CblImportSummaryDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/Cbl/import": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Cbl"
|
|
||||||
],
|
|
||||||
"summary": "Performs the actual import (assuming dryRun = false)",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "dryRun",
|
|
||||||
"in": "query",
|
|
||||||
"description": "If true, will only emulate the import but not perform. This should be done to preview what will happen",
|
|
||||||
"schema": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "comicVineMatching",
|
|
||||||
"in": "query",
|
|
||||||
"description": "Use comic vine matching or not. Defaults to false",
|
|
||||||
"schema": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"description": "FormBody with parameter name of cbl",
|
|
||||||
"content": {
|
|
||||||
"multipart/form-data": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cbl": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "binary"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"encoding": {
|
|
||||||
"cbl": {
|
|
||||||
"style": "form"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"text/plain": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CblImportSummaryDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CblImportSummaryDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"text/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/CblImportSummaryDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/Chapter": {
|
"/api/Chapter": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -9356,6 +9223,23 @@
|
|||||||
"format": "int32",
|
"format": "int32",
|
||||||
"default": 0
|
"default": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "context",
|
||||||
|
"in": "query",
|
||||||
|
"description": "For complex queries, Library has certain restrictions where the library should not be included in results.\r\nThis enum dictates which field to use for the lookup.",
|
||||||
|
"schema": {
|
||||||
|
"enum": [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"type": "integer",
|
||||||
|
"description": "For complex queries, Library has certain restrictions where the library should not be included in results.\r\nThis enum dictates which field to use for the lookup.",
|
||||||
|
"format": "int32",
|
||||||
|
"default": 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@ -10577,6 +10461,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Server/sync-themes": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Server"
|
||||||
|
],
|
||||||
|
"summary": "Runs the Sync Themes task",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Settings/base-url": {
|
"/api/Settings/base-url": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -14593,7 +14490,7 @@
|
|||||||
},
|
},
|
||||||
"hasBeenRated": {
|
"hasBeenRated": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "If the rating has been explicitly set. Otherwise the 0.0 rating should be ignored as it's not rated"
|
"description": "If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated"
|
||||||
},
|
},
|
||||||
"review": {
|
"review": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -14603,7 +14500,8 @@
|
|||||||
"tagline": {
|
"tagline": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "An optional tagline for the review",
|
"description": "An optional tagline for the review",
|
||||||
"nullable": true
|
"nullable": true,
|
||||||
|
"deprecated": true
|
||||||
},
|
},
|
||||||
"seriesId": {
|
"seriesId": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@ -15020,8 +14918,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32"
|
||||||
"nullable": true
|
|
||||||
},
|
},
|
||||||
"description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page",
|
"description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@ -15094,99 +14991,6 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"CblBookResult": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"order": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Order in the CBL",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"series": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"volume": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"number": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"libraryId": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Used on Series conflict",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"seriesId": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Used on Series conflict",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"readingListName": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "The name of the reading list",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"reason": {
|
|
||||||
"enum": [
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5,
|
|
||||||
6,
|
|
||||||
7,
|
|
||||||
8,
|
|
||||||
9
|
|
||||||
],
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"CblImportSummaryDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"cblName": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"fileName": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Used only for Kavita's UI, the filename of the cbl",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"results": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/CblBookResult"
|
|
||||||
},
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"success": {
|
|
||||||
"enum": [
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
2
|
|
||||||
],
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"successfulInserts": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/CblBookResult"
|
|
||||||
},
|
|
||||||
"nullable": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false,
|
|
||||||
"description": "Represents the summary from the Import of a given CBL"
|
|
||||||
},
|
|
||||||
"Chapter": {
|
"Chapter": {
|
||||||
"required": [
|
"required": [
|
||||||
"number",
|
"number",
|
||||||
@ -15970,8 +15774,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32",
|
"format": "int32"
|
||||||
"nullable": true
|
|
||||||
},
|
},
|
||||||
"description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page",
|
"description": "For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@ -17468,7 +17271,8 @@
|
|||||||
12,
|
12,
|
||||||
13,
|
13,
|
||||||
14,
|
14,
|
||||||
15
|
15,
|
||||||
|
16
|
||||||
],
|
],
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"format": "int32"
|
"format": "int32"
|
||||||
@ -23277,10 +23081,6 @@
|
|||||||
"name": "Account",
|
"name": "Account",
|
||||||
"description": "All Account matters"
|
"description": "All Account matters"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "Cbl",
|
|
||||||
"description": "Responsible for the CBL import flow"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Collection",
|
"name": "Collection",
|
||||||
"description": "APIs for Collections"
|
"description": "APIs for Collections"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user