mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Event Widget Updates + Format Downloads + Scanner Work (#3024)
This commit is contained in:
parent
30a8a2555f
commit
a427d02ed1
@ -21,7 +21,6 @@ using AutoMapper;
|
||||
using EasyCaching.Core;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration.UserSecrets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TaskScheduler = API.Services.TaskScheduler;
|
||||
|
||||
@ -134,7 +133,7 @@ public class LibraryController : BaseApiController
|
||||
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
|
||||
|
||||
await _libraryWatcher.RestartWatching();
|
||||
_taskScheduler.ScanLibrary(library.Id);
|
||||
await _taskScheduler.ScanLibrary(library.Id);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
|
||||
@ -292,7 +291,7 @@ public class LibraryController : BaseApiController
|
||||
public async Task<ActionResult> Scan(int libraryId, bool force = false)
|
||||
{
|
||||
if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId"));
|
||||
_taskScheduler.ScanLibrary(libraryId, force);
|
||||
await _taskScheduler.ScanLibrary(libraryId, force);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@ -500,7 +499,7 @@ public class LibraryController : BaseApiController
|
||||
if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
|
||||
{
|
||||
await _libraryWatcher.RestartWatching();
|
||||
_taskScheduler.ScanLibrary(library.Id);
|
||||
await _taskScheduler.ScanLibrary(library.Id);
|
||||
}
|
||||
|
||||
if (folderWatchingUpdate)
|
||||
|
@ -868,6 +868,7 @@ public class OpdsController : BaseApiController
|
||||
SetFeedId(feed, $"series-{series.Id}");
|
||||
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
|
||||
|
||||
var chapterDict = new Dictionary<int, short>();
|
||||
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
|
||||
foreach (var volume in seriesDetail.Volumes)
|
||||
{
|
||||
@ -879,6 +880,7 @@ public class OpdsController : BaseApiController
|
||||
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||
foreach (var mangaFile in chapter.Files)
|
||||
{
|
||||
chapterDict.Add(chapterId, 0);
|
||||
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
|
||||
chapterDto, apiKey, prefix, baseUrl));
|
||||
}
|
||||
@ -892,7 +894,7 @@ public class OpdsController : BaseApiController
|
||||
chapters = seriesDetail.Chapters;
|
||||
}
|
||||
|
||||
foreach (var chapter in chapters.Where(c => !c.IsSpecial))
|
||||
foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id)))
|
||||
{
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
|
||||
var chapterDto = _mapper.Map<ChapterDto>(chapter);
|
||||
|
@ -1,5 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
@ -7,11 +10,15 @@ using API.DTOs.Statistics;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using CsvHelper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MimeTypes;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
@ -24,15 +31,18 @@ public class StatsController : BaseApiController
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
|
||||
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
|
||||
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService)
|
||||
UserManager<AppUser> userManager, ILocalizationService localizationService,
|
||||
ILicenseService licenseService, IDirectoryService directoryService)
|
||||
{
|
||||
_statService = statService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_userManager = userManager;
|
||||
_localizationService = localizationService;
|
||||
_licenseService = licenseService;
|
||||
_directoryService = directoryService;
|
||||
}
|
||||
|
||||
[HttpGet("user/{userId}/read")]
|
||||
@ -111,6 +121,34 @@ public class StatsController : BaseApiController
|
||||
return Ok(await _statService.GetFileBreakdown());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a csv of all file paths for a given extension
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpGet("server/file-extension")]
|
||||
[ResponseCache(CacheProfileName = "Statistics")]
|
||||
public async Task<ActionResult> DownloadFilesByExtension(string fileExtension)
|
||||
{
|
||||
if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions))
|
||||
{
|
||||
return BadRequest("Invalid file format");
|
||||
}
|
||||
var tempFile = Path.Join(_directoryService.TempDirectory,
|
||||
$"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv");
|
||||
|
||||
if (!_directoryService.FileSystem.File.Exists(tempFile))
|
||||
{
|
||||
var results = await _statService.GetFilesByExtension(fileExtension);
|
||||
await using var writer = new StreamWriter(tempFile);
|
||||
await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
|
||||
await csv.WriteRecordsAsync(results);
|
||||
}
|
||||
|
||||
return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)),
|
||||
System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns reading history events for a give or all users, broken up by day, and format
|
||||
|
15
API/DTOs/Stats/FileExtensionExportDto.cs
Normal file
15
API/DTOs/Stats/FileExtensionExportDto.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace API.DTOs.Stats;
|
||||
|
||||
/// <summary>
|
||||
/// Excel export for File Extension Report
|
||||
/// </summary>
|
||||
public class FileExtensionExportDto
|
||||
{
|
||||
[Name("Path")]
|
||||
public string FilePath { get; set; }
|
||||
|
||||
[Name("Extension")]
|
||||
public string Extension { get; set; }
|
||||
}
|
@ -21,6 +21,7 @@ using API.DTOs.Search;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.DTOs.Settings;
|
||||
using API.DTOs.SideNav;
|
||||
using API.DTOs.Stats;
|
||||
using API.DTOs.Theme;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -326,5 +327,8 @@ public class AutoMapperProfiles : Profile
|
||||
opt.MapFrom(src => ReviewService.GetCharacters(src.Body)));
|
||||
|
||||
CreateMap<ExternalRecommendation, ExternalSeriesDto>();
|
||||
|
||||
|
||||
CreateMap<MangaFile, FileExtensionExportDto>();
|
||||
}
|
||||
}
|
||||
|
@ -97,7 +97,7 @@ public class Program
|
||||
Task.Run(async () =>
|
||||
{
|
||||
// Apply all migrations on startup
|
||||
logger.LogInformation("Running Migrations");
|
||||
logger.LogInformation("Running Manual Migrations");
|
||||
|
||||
try
|
||||
{
|
||||
@ -113,7 +113,7 @@ public class Program
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
logger.LogInformation("Running Migrations - complete");
|
||||
logger.LogInformation("Running Manual Migrations - complete");
|
||||
}).GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
@ -134,9 +134,16 @@ public class ImageService : IImageService
|
||||
/// <returns></returns>
|
||||
public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight)
|
||||
{
|
||||
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
|
||||
try
|
||||
{
|
||||
return Enums.Size.Force;
|
||||
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
|
||||
{
|
||||
return Enums.Size.Force;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow */
|
||||
}
|
||||
|
||||
return Enums.Size.Both;
|
||||
@ -144,9 +151,15 @@ public class ImageService : IImageService
|
||||
|
||||
public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight)
|
||||
{
|
||||
|
||||
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
|
||||
try
|
||||
{
|
||||
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
} catch (Exception)
|
||||
{
|
||||
/* Swallow */
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -166,8 +179,8 @@ public class ImageService : IImageService
|
||||
}
|
||||
|
||||
// Calculate scaling factors
|
||||
var widthScaleFactor = (double)targetWidth / sourceImage.Width;
|
||||
var heightScaleFactor = (double)targetHeight / sourceImage.Height;
|
||||
var widthScaleFactor = (double) targetWidth / sourceImage.Width;
|
||||
var heightScaleFactor = (double) targetHeight / sourceImage.Height;
|
||||
|
||||
// Check resolution quality (example thresholds)
|
||||
if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0)
|
||||
@ -219,14 +232,15 @@ public class ImageService : IImageService
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
|
||||
{
|
||||
var (width, height) = size.GetDimensions();
|
||||
stream.Position = 0;
|
||||
var (targetWidth, targetHeight) = size.GetDimensions();
|
||||
if (stream.CanSeek) stream.Position = 0;
|
||||
using var sourceImage = Image.NewFromStream(stream);
|
||||
stream.Position = 0;
|
||||
if (stream.CanSeek) stream.Position = 0;
|
||||
|
||||
using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight,
|
||||
size: GetSizeForDimensions(sourceImage, targetWidth, targetHeight),
|
||||
crop: GetCropForDimensions(sourceImage, targetWidth, targetHeight));
|
||||
|
||||
using var thumbnail = Image.ThumbnailStream(stream, width, height: height,
|
||||
size: GetSizeForDimensions(sourceImage, width, height),
|
||||
crop: GetCropForDimensions(sourceImage, width, height));
|
||||
var filename = fileName + encodeFormat.GetExtension();
|
||||
_directoryService.ExistOrCreate(outputDirectory);
|
||||
try
|
||||
|
@ -501,6 +501,7 @@ public class SeriesService : ISeriesService
|
||||
StorylineChapters = storylineChapters,
|
||||
TotalCount = chapters.Count,
|
||||
UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages),
|
||||
// TODO: See if we can get the ContinueFrom here
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -5,10 +5,12 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Statistics;
|
||||
using API.DTOs.Stats;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using API.Helpers;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using AutoMapper;
|
||||
@ -35,6 +37,7 @@ public interface IStatisticService
|
||||
Task UpdateServerStatistics();
|
||||
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
|
||||
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
|
||||
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -559,6 +562,16 @@ public class StatisticService : IStatisticService
|
||||
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension)
|
||||
{
|
||||
var query = _context.MangaFile
|
||||
.Where(f => f.Extension == fileExtension)
|
||||
.ProjectTo<FileExtensionExportDto>(_mapper.ConfigurationProvider)
|
||||
.OrderBy(f => f.FilePath);
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
|
||||
{
|
||||
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||
|
@ -4,11 +4,13 @@ using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Converters;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -22,12 +24,12 @@ public interface ITaskScheduler
|
||||
Task ScheduleKavitaPlusTasks();
|
||||
void ScanFolder(string folderPath, string originalPath, TimeSpan delay);
|
||||
void ScanFolder(string folderPath);
|
||||
void ScanLibrary(int libraryId, bool force = false);
|
||||
void ScanLibraries(bool force = false);
|
||||
Task ScanLibrary(int libraryId, bool force = false);
|
||||
Task ScanLibraries(bool force = false);
|
||||
void CleanupChapters(int[] chapterIds);
|
||||
void RefreshMetadata(int libraryId, bool forceUpdate = true);
|
||||
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
|
||||
void CancelStatsTasks();
|
||||
@ -57,6 +59,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly IExternalMetadataService _externalMetadataService;
|
||||
private readonly ISmartCollectionSyncService _smartCollectionSyncService;
|
||||
private readonly IEventHub _eventHub;
|
||||
|
||||
public static BackgroundJobServer Client => new ();
|
||||
public const string ScanQueue = "scan";
|
||||
@ -93,7 +96,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
|
||||
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
|
||||
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
|
||||
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService)
|
||||
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
@ -112,6 +115,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
_licenseService = licenseService;
|
||||
_externalMetadataService = externalMetadataService;
|
||||
_smartCollectionSyncService = smartCollectionSyncService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
public async Task ScheduleTasks()
|
||||
@ -320,18 +324,21 @@ public class TaskScheduler : ITaskScheduler
|
||||
/// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future.
|
||||
/// </summary>
|
||||
/// <param name="force"></param>
|
||||
public void ScanLibraries(bool force = false)
|
||||
public async Task ScanLibraries(bool force = false)
|
||||
{
|
||||
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
|
||||
{
|
||||
_logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours");
|
||||
// Send InfoEvent to UI as this is invoked my API
|
||||
BackgroundJob.Schedule(() => ScanLibraries(force), TimeSpan.FromHours(3));
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed",
|
||||
$"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}"));
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force));
|
||||
}
|
||||
|
||||
public void ScanLibrary(int libraryId, bool force = false)
|
||||
public async Task ScanLibrary(int libraryId, bool force = false)
|
||||
{
|
||||
if (HasScanTaskRunningForLibrary(libraryId))
|
||||
{
|
||||
@ -340,18 +347,18 @@ public class TaskScheduler : ITaskScheduler
|
||||
}
|
||||
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
|
||||
{
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
|
||||
_logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan library task delayed",
|
||||
$"A scan was ongoing during processing of the {library!.Name} scan task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}"));
|
||||
BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3));
|
||||
return;
|
||||
}
|
||||
|
||||
// await _eventHub.SendMessageAsync(MessageFactory.Info,
|
||||
// MessageFactory.InfoEvent($"Scan library invoked but a task is already running for {library.Name}. Rescheduling request for 10 mins", string.Empty));
|
||||
|
||||
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
|
||||
var jobId = BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true));
|
||||
// When we do a scan, force cache to re-unpack in case page numbers change
|
||||
BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory());
|
||||
BackgroundJob.ContinueJobWith(jobId, () => _cleanupService.CleanupCacheDirectory());
|
||||
}
|
||||
|
||||
public void TurnOnScrobbling(int userId = 0)
|
||||
@ -392,7 +399,7 @@ public class TaskScheduler : ITaskScheduler
|
||||
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate));
|
||||
}
|
||||
|
||||
public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
|
||||
{
|
||||
if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue))
|
||||
{
|
||||
@ -402,7 +409,10 @@ public class TaskScheduler : ITaskScheduler
|
||||
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
|
||||
{
|
||||
// BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice)
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None);
|
||||
_logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan series task delayed: {series!.Name}",
|
||||
$"A scan was ongoing during processing of the scan series task. Task has been rescheduled for 10 minutes: {DateTime.Now.AddMinutes(10)}"));
|
||||
BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10));
|
||||
return;
|
||||
}
|
||||
|
@ -150,13 +150,28 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
{
|
||||
_logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType);
|
||||
if (e.ChangeType != WatcherChangeTypes.Changed) return;
|
||||
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name))));
|
||||
|
||||
var isDirectoryChange = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
|
||||
|
||||
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange],
|
||||
checkRunningJobs: true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange));
|
||||
}
|
||||
|
||||
private void OnCreated(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
_logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name);
|
||||
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name)));
|
||||
var isDirectoryChange = !_directoryService.FileSystem.File.Exists(e.Name);
|
||||
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange],
|
||||
checkRunningJobs: true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -168,6 +183,11 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
|
||||
if (!isDirectory) return;
|
||||
_logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name);
|
||||
if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, true],
|
||||
checkRunningJobs: true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true));
|
||||
}
|
||||
|
||||
@ -298,7 +318,7 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
/// This is called via Hangfire to decrement the counter. Must work around a lock
|
||||
/// </summary>
|
||||
// ReSharper disable once MemberCanBePrivate.Global
|
||||
public void UpdateLastBufferOverflow()
|
||||
public static void UpdateLastBufferOverflow()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
|
@ -351,14 +351,17 @@ public class ParseScannedFiles
|
||||
{
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started));
|
||||
|
||||
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count());
|
||||
var processedScannedSeries = new List<ScannedSeriesResult>();
|
||||
//var processedScannedSeries = new ConcurrentBag<ScannedSeriesResult>();
|
||||
foreach (var folderPath in folders)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath);
|
||||
var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck);
|
||||
|
||||
_logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath);
|
||||
foreach (var scanResult in scanResults)
|
||||
{
|
||||
await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries);
|
||||
|
@ -76,7 +76,7 @@ public enum ScanCancelReason
|
||||
public class ScannerService : IScannerService
|
||||
{
|
||||
public const string Name = "ScannerService";
|
||||
public const int Timeout = 60 * 60 * 60;
|
||||
private const int Timeout = 60 * 60 * 60; // 2.5 days
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ScannerService> _logger;
|
||||
private readonly IMetadataService _metadataService;
|
||||
@ -157,11 +157,11 @@ public class ScannerService : IScannerService
|
||||
}
|
||||
|
||||
// TODO: Figure out why we have the library type restriction here
|
||||
if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel))
|
||||
if (series != null)// && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel)
|
||||
{
|
||||
if (TaskScheduler.HasScanTaskRunningForSeries(series.Id))
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
|
||||
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder);
|
||||
return;
|
||||
}
|
||||
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder);
|
||||
@ -185,7 +185,7 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id))
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
|
||||
_logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder);
|
||||
return;
|
||||
}
|
||||
BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1));
|
||||
@ -198,20 +198,21 @@ public class ScannerService : IScannerService
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="bypassFolderOptimizationChecks">Not Used. Scan series will always force</param>
|
||||
[Queue(TaskScheduler.ScanQueue)]
|
||||
[DisableConcurrentExecution(Timeout)]
|
||||
[AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true)
|
||||
{
|
||||
if (TaskScheduler.HasAlreadyEnqueuedTask(Name, "ScanSeries", [seriesId, bypassFolderOptimizationChecks], TaskScheduler.ScanQueue))
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Dropping request");
|
||||
return;
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
|
||||
if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update
|
||||
|
||||
// if (TaskScheduler.HasScanTaskRunningForSeries(seriesId))
|
||||
// {
|
||||
// _logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Rescheduling request for 1 mins");
|
||||
// BackgroundJob.Schedule(() => ScanSeries(seriesId, bypassFolderOptimizationChecks), TimeSpan.FromMinutes(1));
|
||||
// return;
|
||||
// }
|
||||
|
||||
var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
|
||||
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns);
|
||||
@ -444,7 +445,7 @@ public class ScannerService : IScannerService
|
||||
// Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are
|
||||
if (folders.Any(f => !_directoryService.IsDriveMounted(f)))
|
||||
{
|
||||
_logger.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
|
||||
_logger.LogCritical("[ScannerService] Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Error,
|
||||
MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted",
|
||||
@ -458,7 +459,7 @@ public class ScannerService : IScannerService
|
||||
if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
|
||||
{
|
||||
// That way logging and UI informing is all in one place with full context
|
||||
_logger.LogError("Some of the root folders for the library are empty. " +
|
||||
_logger.LogError("[ScannerService] Some of the root folders for the library are empty. " +
|
||||
"Either your mount has been disconnected or you are trying to delete all series in the library. " +
|
||||
"Scan has be aborted. " +
|
||||
"Check that your mount is connected or change the library's root folder and rescan");
|
||||
@ -479,22 +480,23 @@ public class ScannerService : IScannerService
|
||||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task ScanLibraries(bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
|
||||
_logger.LogInformation("[ScannerService] Starting Scan of All Libraries, Forced: {Forced}", forceUpdate);
|
||||
foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync())
|
||||
{
|
||||
if (TaskScheduler.RunningAnyTasksByMethod(TaskScheduler.ScanTasks, TaskScheduler.ScanQueue))
|
||||
// BUG: This will trigger the first N libraries to scan over and over if there is always an interruption later in the chain
|
||||
if (TaskScheduler.HasScanTaskRunningForLibrary(lib.Id))
|
||||
{
|
||||
_logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running. Rescheduling for 4 hours");
|
||||
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed",
|
||||
$"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for {DateTime.UtcNow.AddHours(4)} UTC"));
|
||||
BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4));
|
||||
return;
|
||||
// We don't need to send SignalR event as this is a background job that user doesn't need insight into
|
||||
_logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name);
|
||||
await Task.Delay(TimeSpan.FromHours(4));
|
||||
//BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4));
|
||||
//return;
|
||||
}
|
||||
|
||||
await ScanLibrary(lib.Id, forceUpdate, true);
|
||||
}
|
||||
_processSeries.Reset();
|
||||
_logger.LogInformation("Scan of All Libraries Finished");
|
||||
_logger.LogInformation("[ScannerService] Scan of All Libraries Finished");
|
||||
}
|
||||
|
||||
|
||||
@ -526,23 +528,27 @@ public class ScannerService : IScannerService
|
||||
var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
|
||||
if (!shouldUseLibraryScan)
|
||||
{
|
||||
_logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
|
||||
_logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan Files", library.Name);
|
||||
var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths,
|
||||
shouldUseLibraryScan, forceUpdate);
|
||||
|
||||
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name);
|
||||
var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
|
||||
TrackFoundSeriesAndFiles(parsedSeries, processedSeries);
|
||||
|
||||
// We need to remove any keys where there is no actual parser info
|
||||
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Process Parsed Series", library.Name);
|
||||
var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime);
|
||||
|
||||
UpdateLastScanned(library);
|
||||
|
||||
|
||||
_unitOfWork.LibraryRepository.Update(library);
|
||||
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name);
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
if (isSingleScan)
|
||||
@ -563,6 +569,7 @@ public class ScannerService : IScannerService
|
||||
totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name);
|
||||
}
|
||||
|
||||
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name);
|
||||
await RemoveSeriesNotFound(parsedSeries, library);
|
||||
}
|
||||
else
|
||||
|
@ -345,6 +345,7 @@ public static class MessageFactory
|
||||
EventType = ProgressEventType.Single,
|
||||
Body = new
|
||||
{
|
||||
Name = Error,
|
||||
Title = title,
|
||||
SubTitle = subtitle,
|
||||
}
|
||||
@ -362,6 +363,7 @@ public static class MessageFactory
|
||||
EventType = ProgressEventType.Single,
|
||||
Body = new
|
||||
{
|
||||
Name = Info,
|
||||
Title = title,
|
||||
SubTitle = subtitle,
|
||||
}
|
||||
|
@ -427,8 +427,8 @@ public class Startup
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow Exception */
|
||||
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
|
||||
}
|
||||
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
|
||||
});
|
||||
|
||||
logger.LogInformation("Starting with base url as {BaseUrl}", basePath);
|
||||
|
@ -2,7 +2,7 @@
|
||||
"TokenKey": "super secret unguessable key that is longer because we require it",
|
||||
"Port": 5000,
|
||||
"IpAddresses": "0.0.0.0,::",
|
||||
"BaseUrl": "/tes/",
|
||||
"BaseUrl": "/",
|
||||
"Cache": 75,
|
||||
"AllowIFraming": false
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {Inject, inject, Injectable} from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
|
||||
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe';
|
||||
import { map } from 'rxjs';
|
||||
import {asyncScheduler, finalize, map, tap} from 'rxjs';
|
||||
import { MangaFormatPipe } from '../_pipes/manga-format.pipe';
|
||||
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
|
||||
import { TopUserRead } from '../statistics/_models/top-reads';
|
||||
@ -15,6 +15,10 @@ import { MangaFormat } from '../_models/manga-format';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
||||
import {throttleTime} from "rxjs/operators";
|
||||
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
|
||||
import {download} from "../shared/_models/download";
|
||||
import {Saver, SAVER} from "../_providers/saver.provider";
|
||||
|
||||
export enum DayOfWeek
|
||||
{
|
||||
@ -37,7 +41,7 @@ export class StatisticsService {
|
||||
publicationStatusPipe = new PublicationStatusPipe(this.translocoService);
|
||||
mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
||||
|
||||
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
||||
// TODO: Convert to httpParams object
|
||||
@ -109,6 +113,20 @@ export class StatisticsService {
|
||||
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
||||
}
|
||||
|
||||
downloadFileBreakdown(extension: string) {
|
||||
return this.httpClient.get(this.baseUrl + 'stats/server/file-extension?fileExtension=' + encodeURIComponent(extension),
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, decodeURIComponent(filename));
|
||||
}),
|
||||
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)),
|
||||
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
getReadCountByDay(userId: number = 0, days: number = 0) {
|
||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
import {ChangeDetectorRef, Directive, EventEmitter, inject, Input, OnInit, Output} from "@angular/core";
|
||||
|
||||
export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
|
||||
export type SortColumn<T> = keyof T | '';
|
||||
@ -11,6 +11,7 @@ export interface SortEvent<T> {
|
||||
}
|
||||
|
||||
@Directive({
|
||||
// eslint-disable-next-line @angular-eslint/directive-selector
|
||||
selector: 'th[sortable]',
|
||||
host: {
|
||||
'[class.asc]': 'direction === "asc"',
|
||||
@ -29,4 +30,4 @@ export class SortableHeader<T> {
|
||||
this.direction = rotate[this.direction];
|
||||
this.sort.emit({ column: this.sortable, direction: this.direction });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,27 +4,33 @@
|
||||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
<span>{{libraryName}}</span>
|
||||
</h2>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
@if (active.fragment === '') {
|
||||
<h6 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
}
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
|
||||
<app-card-detail-layout *ngIf="filter"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[refresh]="refresh"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
@if (filter) {
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[refresh]="refresh"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
}
|
||||
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
@ -33,18 +33,18 @@ import {SentenceCasePipe} from '../_pipes/sentence-case.pipe';
|
||||
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
|
||||
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
|
||||
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import {DecimalPipe, NgFor, NgIf} from '@angular/common';
|
||||
import {DecimalPipe} from '@angular/common';
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {MetadataService} from "../_services/metadata.service";
|
||||
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
import {debounceTime, ReplaySubject, tap} from "rxjs";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-detail',
|
||||
@ -52,14 +52,25 @@ import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
styleUrls: ['./library-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
|
||||
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent,
|
||||
CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
|
||||
})
|
||||
export class LibraryDetailComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly hubService = inject(MessageHubService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
public readonly navService = inject(NavService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
|
||||
libraryId!: number;
|
||||
libraryName = '';
|
||||
@ -82,6 +93,8 @@ export class LibraryDetailComponent implements OnInit {
|
||||
];
|
||||
active = this.tabs[0];
|
||||
|
||||
loadPageSource = new ReplaySubject(1);
|
||||
loadPage$ = this.loadPageSource.asObservable();
|
||||
|
||||
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
|
||||
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
@ -142,10 +155,8 @@ export class LibraryDetailComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
|
||||
|
||||
constructor() {
|
||||
const routeId = this.route.snapshot.paramMap.get('libraryId');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
@ -180,6 +191,8 @@ export class LibraryDetailComponent implements OnInit {
|
||||
|
||||
this.filterSettings.presetsV2 = this.filter;
|
||||
|
||||
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe();
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
@ -191,7 +204,7 @@ export class LibraryDetailComponent implements OnInit {
|
||||
const seriesAdded = event.payload as SeriesAddedEvent;
|
||||
if (seriesAdded.libraryId !== this.libraryId) return;
|
||||
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
|
||||
this.loadPage();
|
||||
this.loadPageSource.next(true);
|
||||
return;
|
||||
}
|
||||
this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => {
|
||||
@ -211,7 +224,7 @@ export class LibraryDetailComponent implements OnInit {
|
||||
const seriesRemoved = event.payload as SeriesRemovedEvent;
|
||||
if (seriesRemoved.libraryId !== this.libraryId) return;
|
||||
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
|
||||
this.loadPage(); // TODO: This can be quite expensive when bulk deleting. We can refactor this to an ReplaySubject to debounce
|
||||
this.loadPageSource.next(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -286,12 +299,12 @@ export class LibraryDetailComponent implements OnInit {
|
||||
this.filter = data.filterV2;
|
||||
|
||||
if (data.isFirst) {
|
||||
this.loadPage();
|
||||
this.loadPageSource.next(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
|
||||
this.loadPage();
|
||||
this.loadPageSource.next(true);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,139 +1,118 @@
|
||||
<ng-container *transloco="let t; read: 'events-widget'">
|
||||
<ng-container *ngIf="isAdmin$ | async">
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngIf="infos$ | async as infos">
|
||||
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0, 'colored-error': errors.length > 0,
|
||||
'colored-info': infos.length > 0 && errors.length === 0}"
|
||||
[ngbPopover]="popContent" [title]="t('title-alt')" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
@if (isAdmin$ | async) {
|
||||
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
|
||||
@if (errors$ | async; as errors) {
|
||||
@if (infos$ | async; as infos) {
|
||||
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
|
||||
<button type="button" class="btn btn-icon"
|
||||
[ngbPopover]="popContent" [title]="t('title-alt')"
|
||||
placement="bottom" [popoverClass]="'nav-events'"
|
||||
[autoClose]="'outside'">
|
||||
|
||||
@if (onlineUsers.length > 1) {
|
||||
<span class="me-2" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}">{{onlineUsers.length}}</span>
|
||||
}
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}"></i>
|
||||
|
||||
|
||||
@if (errors.length > 0) {
|
||||
<i aria-hidden="true" class="fa fa-circle-exclamation nav widget-button--indicator error"></i>
|
||||
} @else if (infos.length > 0) {
|
||||
<i aria-hidden="true" class="fa fa-circle-info nav widget-button--indicator info"></i>
|
||||
} @else if (activeEvents > 0 || activeDownloads.length > 0) {
|
||||
<div class="nav widget-button--indicator spinner-border spinner-border-sm"></div>
|
||||
} @else if (updateAvailable) {
|
||||
<i aria-hidden="true" class="fa fa-circle-arrow-up nav widget-button--indicator update"></i>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<ng-template #popContent>
|
||||
|
||||
<ul class="list-group list-group-flush dark-menu">
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngIf="infos$ | async as infos">
|
||||
<li class="list-group-item dark-menu-item clickable" *ngIf="errors.length > 0 || infos.length > 0" (click)="clearAllErrorOrInfos()">
|
||||
{{t('dismiss-all')}}
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@if (debugMode) {
|
||||
<ng-container>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">Title goes here</div>
|
||||
<div class="accent-text mb-1">Subtitle goes here</div>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="progress" style="height: 5px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">Title goes here</div>
|
||||
<div class="accent-text mb-1">Subtitle goes here</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div>
|
||||
<div class="h6 mb-1">Scanning Books</div>
|
||||
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item error">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
|
||||
<div class="accent-text mb-1">Click for more information</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item info">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
|
||||
<div class="accent-text mb-1">Click for more information</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="d-inline-flex">
|
||||
<span class="download">
|
||||
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
10% downloaded
|
||||
</span>
|
||||
</span>
|
||||
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
|
||||
</div>
|
||||
<div class="accent-text">PDFs</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
@if(errors$ | async; as errors) {
|
||||
@if(infos$ | async; as infos) {
|
||||
@if (errors.length > 0 || infos.length > 0) {
|
||||
<li class="list-group-item dark-menu-item clickable" (click)="clearAllErrorOrInfos()">
|
||||
{{t('dismiss-all')}}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<!-- Progress Events-->
|
||||
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
|
||||
<ng-container *ngFor="let message of progressUpdates">
|
||||
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
|
||||
<div class="h6 mb-1">{{message.title}}</div>
|
||||
@if (message.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
}
|
||||
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
|
||||
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}</div>
|
||||
}
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
@if(message.progress === 'indeterminate') {
|
||||
<div class="progress" style="height: 5px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
<ng-template #progressEvent>
|
||||
@if (progressEvents$ | async; as progressUpdates) {
|
||||
@for (message of progressUpdates; track message) {
|
||||
@if (message.progress === 'indeterminate' || message.progress === 'none') {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">{{message.title}}</div>
|
||||
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
@if (message.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
}
|
||||
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
|
||||
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">
|
||||
{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}
|
||||
</div>
|
||||
}
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
@if(message.progress === 'indeterminate') {
|
||||
<div class="progress" style="height: 5px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
} @else {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">{{message.title}}</div>
|
||||
@if (message.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
}
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{prettyPrintProgress(message.body.progress) + '%'}}</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': message.body.progress * 100 + '%'}" [attr.aria-valuenow]="message.body.progress * 100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
<div class="progress-bar" role="progressbar"
|
||||
[ngStyle]="{'width': message.body.progress * 100 + '%'}"
|
||||
[attr.aria-valuenow]="message.body.progress * 100"
|
||||
aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Single updates (Informational/Update available)-->
|
||||
<ng-container *ngIf="singleUpdates$ | async as singleUpdates">
|
||||
<ng-container *ngFor="let singleUpdate of singleUpdates">
|
||||
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name === EVENTS.UpdateAvailable" (click)="handleUpdateAvailableClick(singleUpdate)">
|
||||
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name !== EVENTS.UpdateAvailable">
|
||||
<div>{{singleUpdate.title}}</div>
|
||||
<div class="accent-text" *ngIf="singleUpdate.subTitle !== ''">{{singleUpdate.subTitle}}</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@if (singleUpdates$ | async; as singleUpdates) {
|
||||
@for(singleUpdate of singleUpdates; track singleUpdate) {
|
||||
@if (singleUpdate.name === EVENTS.UpdateAvailable) {
|
||||
<li class="list-group-item dark-menu-item update-available" (click)="handleUpdateAvailableClick(singleUpdate)">
|
||||
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
|
||||
</li>
|
||||
} @else {
|
||||
<li class="list-group-item dark-menu-item update-available">
|
||||
<div>{{singleUpdate.title}}</div>
|
||||
@if (singleUpdate.subTitle !== '') {
|
||||
<div class="accent-text">{{singleUpdate.subTitle}}</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Active Downloads by the user-->
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngFor="let download of activeDownloads">
|
||||
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
|
||||
@for(download of activeDownloads; track download) {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">{{t('downloading-item', {item: download.entityType | sentenceCase})}}</div>
|
||||
<div class="accent-text mb-1" *ngIf="download.subTitle !== ''" [title]="download.subTitle">{{download.subTitle}}</div>
|
||||
|
||||
@if (download.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="download.subTitle">{{download.subTitle}}</div>
|
||||
}
|
||||
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{download.progress}}%</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
@ -141,57 +120,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
@if(activeDownloads.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">{{activeDownloads.length}} downloads in Queue</li>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@if(activeDownloads.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">{{t('download-in-queue', {num: activeDownloads.length})}}</li>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Errors -->
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngFor="let error of errors">
|
||||
@if (errors$ | async; as errors) {
|
||||
@for (error of errors; track error) {
|
||||
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMore(error)">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>{{error.title}}</div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2" aria-hidden="true"></i>{{error.title}}</div>
|
||||
<div class="accent-text mb-1">{{t('more-info')}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(error, $event)"></button>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Infos -->
|
||||
<ng-container *ngIf="infos$ | async as infos">
|
||||
<ng-container *ngFor="let info of infos">
|
||||
@if (infos$ | async; as infos) {
|
||||
@for (info of infos; track info) {
|
||||
<li class="list-group-item dark-menu-item info" role="alert" (click)="seeMore(info)">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>{{info.title}}</div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2" aria-hidden="true"></i>{{info.title}}</div>
|
||||
<div class="accent-text mb-1">{{t('more-info')}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(info, $event)"></button>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Online Users -->
|
||||
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
|
||||
@if (onlineUsers.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (debugMode) {
|
||||
<li class="list-group-item dark-menu-item">{{t('active-events-title')}} {{activeEvents}}</li>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li>
|
||||
</ng-container>
|
||||
|
||||
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
|
||||
@if (errors$ | async; as errors) {
|
||||
@if (infos$ | async; as infos) {
|
||||
@if (infos.length === 0 && errors.length === 0 && activeDownloads.length === 0 && activeEvents === 0) {
|
||||
<li class="list-group-item dark-menu-item">{{t('no-data')}}</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
</ng-container>
|
||||
|
@ -14,6 +14,26 @@
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.colored {
|
||||
color: var(--event-widget-activity-bg-color) !important;
|
||||
}
|
||||
|
||||
.widget-button--indicator {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
color: var(--event-widget-activity-bg-color);
|
||||
|
||||
&.error {
|
||||
color: var(--event-widget-error-bg-color) !important;
|
||||
}
|
||||
&.info {
|
||||
color: var(--event-widget-info-bg-color) !important;
|
||||
}
|
||||
&.update {
|
||||
color: var(--event-widget-update-bg-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .nav-events {
|
||||
|
||||
.popover-body {
|
||||
@ -56,67 +76,56 @@
|
||||
|
||||
|
||||
.btn-icon {
|
||||
color: white;
|
||||
color: var(--event-widget-text-color);
|
||||
}
|
||||
|
||||
.colored {
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.colored-error {
|
||||
background-color: var(--error-color) !important;
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.colored-info {
|
||||
background-color: var(--event-widget-info-bg-color) !important;
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.update-available {
|
||||
.dark-menu-item {
|
||||
&.update-available {
|
||||
cursor: pointer;
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
&.error {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
.h6 {
|
||||
color: var(--error-color);
|
||||
color: var(--event-widget-error-bg-color);
|
||||
}
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
&.info {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
.h6 {
|
||||
color: var(--event-widget-info-bg-color);
|
||||
color: var(--event-widget-info-bg-color);
|
||||
}
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hu
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe';
|
||||
import { CircularLoaderComponent } from '../../../shared/circular-loader/circular-loader.component';
|
||||
import { NgIf, NgClass, NgStyle, NgFor, AsyncPipe } from '@angular/common';
|
||||
import { NgClass, NgStyle, AsyncPipe } from '@angular/common';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
@ -34,12 +34,20 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
||||
styleUrls: ['./events-widget.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgClass, NgbPopover, NgStyle, CircularLoaderComponent, NgFor, AsyncPipe, SentenceCasePipe, TranslocoDirective]
|
||||
imports: [NgClass, NgbPopover, NgStyle, CircularLoaderComponent, AsyncPipe, SentenceCasePipe, TranslocoDirective]
|
||||
})
|
||||
export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
@Input({required: true}) user!: User;
|
||||
public readonly downloadService = inject(DownloadService);
|
||||
public readonly messageHub = inject(MessageHubService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@Input({required: true}) user!: User;
|
||||
|
||||
|
||||
isAdmin$: Observable<boolean> = of(false);
|
||||
|
||||
/**
|
||||
@ -60,17 +68,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||
|
||||
activeEvents: number = 0;
|
||||
/**
|
||||
* Intercepts from Single Updates to show an extra indicator to the user
|
||||
*/
|
||||
updateAvailable: boolean = false;
|
||||
|
||||
debugMode: boolean = false;
|
||||
|
||||
protected readonly EVENTS = EVENTS;
|
||||
|
||||
public readonly downloadService = inject(DownloadService);
|
||||
|
||||
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
||||
private accountService: AccountService, private confirmService: ConfirmService,
|
||||
private readonly cdRef: ChangeDetectorRef) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.progressEventsSource.complete();
|
||||
@ -115,6 +121,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
values.push(message);
|
||||
this.singleUpdateSource.next(values);
|
||||
this.activeEvents += 1;
|
||||
if (event.payload.name === EVENTS.UpdateAvailable) {
|
||||
this.updateAvailable = true;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
break;
|
||||
case 'started':
|
||||
|
@ -1,346 +1,393 @@
|
||||
<ng-container *transloco="let t; read: 'series-detail'">
|
||||
<div #companionBar>
|
||||
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
|
||||
<ng-container title>
|
||||
<h2 class="title text-break">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<span>{{series.name}}
|
||||
@if(isLoadingExtra || isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
@if (series) {
|
||||
<app-side-nav-companion-bar [hasExtras]="true" [extraDrawer]="extrasDrawer">
|
||||
<ng-container title>
|
||||
<h2 class="title text-break">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<span>{{series.name}}
|
||||
@if(isLoadingExtra || isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">loading...</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</h2>
|
||||
</ng-container>
|
||||
<ng-container subtitle *ngIf="series.localizedName !== series.name">
|
||||
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
|
||||
</ng-container>
|
||||
</h2>
|
||||
</ng-container>
|
||||
@if (series.localizedName !== series.name) {
|
||||
<ng-container subtitle>
|
||||
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px">
|
||||
<div class="offcanvas-header">
|
||||
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="offcanvas.dismiss()"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<form [formGroup]="pageExtrasGroup">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-3">
|
||||
<label id="list-layout-mode-label" class="form-label">{{t('layout-mode-label')}}</label>
|
||||
<br/>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('page-settings-title')">
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-card')}}</label>
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px">
|
||||
<div class="offcanvas-header">
|
||||
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="offcanvas.dismiss()"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<form [formGroup]="pageExtrasGroup">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-3">
|
||||
<label id="list-layout-mode-label" class="form-label">{{t('layout-mode-label')}}</label>
|
||||
<br/>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('page-settings-title')">
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-card')}}</label>
|
||||
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
</app-side-nav-companion-bar>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback" [topOffset]="56"></app-bulk-operations>
|
||||
|
||||
@if (series) {
|
||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" #scrollingBlock>
|
||||
<div class="row mb-0 mb-xl-3 info-container">
|
||||
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block mt-2">
|
||||
@if (unreadCount > 0 && unreadCount !== totalCount) {
|
||||
<div class="to-read-counter">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="series !== undefined" #scrollingBlock>
|
||||
<div class="row mb-0 mb-xl-3 info-container">
|
||||
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block mt-2">
|
||||
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
|
||||
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
|
||||
@if (series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial) {
|
||||
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="under-image">
|
||||
{{t('continue-from', {title: ContinuePointTitle})}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
|
||||
<ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
|
||||
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="under-image">
|
||||
{{t('continue-from', {title: ContinuePointTitle})}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" (click)="read()">
|
||||
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
<span>
|
||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
|
||||
<span>
|
||||
<i class="{{isWantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2" *ngIf="isAdmin">
|
||||
<button class="btn btn-secondary" id="edit-btn--komf" (click)="openEditSeriesModal()" [title]="t('edit-series-alt')">
|
||||
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<div class="card-actions">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
||||
</button>
|
||||
</div>
|
||||
@if (isAdmin) {
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" id="edit-btn--komf" (click)="openEditSeriesModal()" [title]="t('edit-series-alt')">
|
||||
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<div class="card-actions">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isAdmin || hasDownloadingRole) {
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
@if (download$ | async; as download) {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
|
||||
@if (download !== null) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||
} @else {
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
|
||||
@if (download$ | async; as download) {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
|
||||
<ng-container *ngIf="download !== null; else notDownloading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||
</ng-container>
|
||||
<ng-template #notDownloading>
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (seriesMetadata) {
|
||||
<div class="mt-2">
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||
[libraryType]="libraryType" [ratings]="ratings"
|
||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@if (seriesMetadata) {
|
||||
<div class="mt-2">
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||
[libraryType]="libraryType" [ratings]="ratings"
|
||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
|
||||
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
|
||||
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
|
||||
[clickableTitle]="true" (sectionClick)="openReviewModal()">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
|
||||
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
|
||||
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
|
||||
[clickableTitle]="true" (sectionClick)="openReviewModal()">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
</div>
|
||||
@if (series) {
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="true" (navChange)="onNavChange($event)">
|
||||
|
||||
<ng-container *ngIf="series">
|
||||
@if (showStorylineTab) {
|
||||
<li [ngbNavItem]="TabID.Storyline">
|
||||
<a ngbNavLink>{{t('storyline-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
|
||||
<li [ngbNavItem]="TabID.Storyline" *ngIf="ShowStorylineTab">
|
||||
<a ngbNavLink>{{t('storyline-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else storylineListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
|
||||
{{item.id}}
|
||||
<ng-container [ngSwitch]="item.isChapter">
|
||||
<ng-container *ngSwitchCase="false" [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
|
||||
<ng-container *ngSwitchCase="true" [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #storylineListLayout>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
|
||||
<ng-container [ngSwitch]="item.isChapter">
|
||||
<ng-container *ngSwitchCase="false" [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
|
||||
<ng-container *ngSwitchCase="true" [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
@if (item.isChapter) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
|
||||
} @else {
|
||||
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
@if (item.isChapter) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
|
||||
} @else {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Volumes" *ngIf="ShowVolumeTab">
|
||||
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else volumeListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
|
||||
</ng-container>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #volumeListLayout>
|
||||
<ng-container *ngFor="let volume of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: volume}"></ng-container>
|
||||
</ng-container>
|
||||
|
||||
@if (showVolumeTab) {
|
||||
<li [ngbNavItem]="TabID.Volumes">
|
||||
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
|
||||
}
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Chapters" *ngIf="ShowChaptersTab">
|
||||
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else chapterListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<div *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container>
|
||||
@if (showChapterTab) {
|
||||
<li [ngbNavItem]="TabID.Chapters">
|
||||
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container>
|
||||
}
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (hasSpecials) {
|
||||
<li [ngbNavItem]="TabID.Specials">
|
||||
<a ngbNavLink>{{t('specials-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (hasRelations) {
|
||||
<li [ngbNavItem]="TabID.Related">
|
||||
<a ngbNavLink>{{t('related-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||
}
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #chapterListLayout>
|
||||
<div *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
|
||||
<a ngbNavLink>{{t('specials-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else specialListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #specialListLayout>
|
||||
<ng-container *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container>
|
||||
</ng-container>
|
||||
@if (hasRecommendations) {
|
||||
<li [ngbNavItem]="TabID.Recommendations">
|
||||
<a ngbNavLink>{{t('recommendations-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track idx) {
|
||||
@if (!item.hasOwnProperty('coverUrl')) {
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
|
||||
} @else {
|
||||
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track idx) {
|
||||
@if (!item.hasOwnProperty('coverUrl')) {
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
} @else {
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Related" *ngIf="hasRelations">
|
||||
<a ngbNavLink>{{t('related-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems let idx = index; trackBy: trackByRelatedSeriesIdentify">
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||
</ng-container>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Recommendations" *ngIf="hasRecommendations">
|
||||
<a ngbNavLink>{{t('recommendations-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else recListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
|
||||
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
|
||||
</ng-container>
|
||||
<ng-template #externalRec>
|
||||
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #recListLayout>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
|
||||
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
|
||||
<app-external-list-item [imageUrl]="imageService.getSeriesCoverImage(item.id)" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, false); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
</ng-container>
|
||||
<ng-template #externalRec>
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
}
|
||||
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
</ng-container>
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
</div>
|
||||
<ng-template #estimatedNextCard let-tabId="tabId">
|
||||
@if (nextExpectedChapter) {
|
||||
@switch (tabId) {
|
||||
@case (TabID.Volumes) {
|
||||
@if (nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber) {
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter"
|
||||
[imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
}
|
||||
}
|
||||
@case (TabID.Chapters) {
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
}
|
||||
@case (TabID.Storyline) {
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
</div>
|
||||
<ng-template #estimatedNextCard let-tabId="tabId">
|
||||
<ng-container *ngIf="nextExpectedChapter">
|
||||
<ng-container [ngSwitch]="tabId">
|
||||
<ng-container *ngSwitchCase="TabID.Volumes">
|
||||
<app-next-expected-card *ngIf="nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber"
|
||||
class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter"
|
||||
[imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="TabID.Chapters">
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="TabID.Storyline">
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #nonSpecialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
|
||||
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||
[count]="item.files.length"
|
||||
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
@if (!item.isSpecial) {
|
||||
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title" (click)="openChapter(item)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||
[count]="item.files.length"
|
||||
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #nonChapterVolumeCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
|
||||
<app-card-item *ngIf="item.number !== LooseLeafOrSpecialNumber" class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
@if (item.number !== LooseLeafOrSpecialNumber) {
|
||||
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #specialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
|
||||
@ -354,27 +401,32 @@
|
||||
</ng-template>
|
||||
|
||||
<ng-template #nonSpecialChapterListItem let-item>
|
||||
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item" *ngIf="!item.isSpecial"
|
||||
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
@if (!item.isSpecial) {
|
||||
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item"
|
||||
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #nonSpecialVolumeListItem let-item>
|
||||
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item" *ngIf="item.number !== LooseLeafOrSpecialNumber"
|
||||
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
@if (item.number !== LooseLeafOrSpecialNumber) {
|
||||
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item"
|
||||
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
}
|
||||
|
||||
|
||||
</ng-template><ng-template #specialChapterListItem let-item>
|
||||
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
|
||||
|
@ -104,7 +104,7 @@ import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {ExternalSeries} from "../../../_models/series-detail/external-series";
|
||||
import {
|
||||
@ -282,6 +282,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
});
|
||||
|
||||
user: User | undefined;
|
||||
showVolumeTab = true;
|
||||
showStorylineTab = true;
|
||||
showChapterTab = true;
|
||||
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
@ -337,28 +340,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
}
|
||||
|
||||
get ShowStorylineTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) return false;
|
||||
// Edge case for bad pdf parse
|
||||
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
|
||||
|
||||
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
|
||||
&& (this.volumes.length > 0 || this.chapters.length > 0);
|
||||
}
|
||||
|
||||
get ShowVolumeTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) {
|
||||
if (this.volumes.length > 1) return true;
|
||||
if (this.specials.length === 0 && this.chapters.length === 0) return true;
|
||||
return false;
|
||||
}
|
||||
return this.volumes.length > 0;
|
||||
}
|
||||
|
||||
get ShowChaptersTab() {
|
||||
return this.chapters.length > 0;
|
||||
}
|
||||
|
||||
get UseBookLogic() {
|
||||
return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel;
|
||||
}
|
||||
@ -380,26 +361,43 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
if (!this.currentlyReadingChapter.isSpecial) {
|
||||
const vol = this.volumes.filter(v => v.id === this.currentlyReadingChapter?.volumeId);
|
||||
|
||||
let chapterLocaleKey = 'common.chapter-num-shorthand';
|
||||
let volumeLocaleKey = 'common.volume-num-shorthand';
|
||||
switch (this.libraryType) {
|
||||
case LibraryType.ComicVine:
|
||||
case LibraryType.Comic:
|
||||
chapterLocaleKey = 'common.issue-num-shorthand';
|
||||
break;
|
||||
case LibraryType.Book:
|
||||
case LibraryType.Manga:
|
||||
case LibraryType.LightNovel:
|
||||
case LibraryType.Images:
|
||||
chapterLocaleKey = 'common.chapter-num-shorthand';
|
||||
break;
|
||||
}
|
||||
|
||||
// This is a lone chapter
|
||||
if (vol.length === 0) {
|
||||
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
|
||||
return this.currentlyReadingChapter.titleName;
|
||||
}
|
||||
return 'Ch ' + this.currentlyReadingChapter.minNumber; // TODO: Refactor this to use DisplayTitle (or Range) and Localize it
|
||||
return translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
|
||||
}
|
||||
|
||||
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
|
||||
return 'Vol ' + vol[0].minNumber;
|
||||
return translate(chapterLocaleKey, {num: vol[0].minNumber});
|
||||
}
|
||||
return 'Vol ' + vol[0].minNumber + ' Ch ' + this.currentlyReadingChapter.minNumber;
|
||||
return translate(volumeLocaleKey, {num: vol[0].minNumber})
|
||||
+ ' ' + translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
|
||||
}
|
||||
|
||||
return this.currentlyReadingChapter.title;
|
||||
}
|
||||
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
this.accountService.currentUser$.subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
@ -415,6 +413,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.scrollService.setScrollContainer(this.scrollingBlock);
|
||||
}
|
||||
|
||||
debugLog(message: string) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
const routeId = this.route.snapshot.paramMap.get('seriesId');
|
||||
@ -424,7 +426,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup the download in progress
|
||||
// Set up the download in progress
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
return this.downloadService.mapToEntityType(events, this.series);
|
||||
}));
|
||||
@ -652,12 +654,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
|
||||
|
||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||
.filter(action => action.action !== Action.Edit);
|
||||
|
||||
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
||||
|
||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||
.filter(action => action.action !== Action.Edit);
|
||||
|
||||
|
||||
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
|
||||
@ -677,6 +677,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
...relations.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)),
|
||||
...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)),
|
||||
];
|
||||
|
||||
if (this.relations.length > 0) {
|
||||
this.hasRelations = true;
|
||||
this.cdRef.markForCheck();
|
||||
@ -690,7 +691,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.router.navigateByUrl('/home');
|
||||
return of(null);
|
||||
})).subscribe(detail => {
|
||||
if (detail == null) return;
|
||||
if (detail == null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
this.unreadCount = detail.unreadCount;
|
||||
this.totalCount = detail.totalCount;
|
||||
|
||||
@ -700,6 +705,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.chapters = detail.chapters;
|
||||
this.volumes = detail.volumes;
|
||||
this.storyChapters = detail.storylineChapters;
|
||||
|
||||
this.storylineItems = [];
|
||||
const v = this.volumes.map(v => {
|
||||
return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem;
|
||||
@ -710,10 +716,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
});
|
||||
this.storylineItems.push(...c);
|
||||
|
||||
|
||||
this.updateWhichTabsToShow();
|
||||
this.updateSelectedTab();
|
||||
|
||||
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('isLoading is now false')
|
||||
});
|
||||
}, err => {
|
||||
this.router.navigateByUrl('/home');
|
||||
@ -724,6 +733,35 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
return {series, relation} as RelatedSeriesPair;
|
||||
}
|
||||
|
||||
shouldShowStorylineTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) return false;
|
||||
// Edge case for bad pdf parse
|
||||
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
|
||||
|
||||
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
|
||||
&& (this.volumes.length > 0 || this.chapters.length > 0);
|
||||
}
|
||||
|
||||
shouldShowVolumeTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) {
|
||||
if (this.volumes.length > 1) return true;
|
||||
if (this.specials.length === 0 && this.chapters.length === 0) return true;
|
||||
return false;
|
||||
}
|
||||
return this.volumes.length > 0;
|
||||
}
|
||||
|
||||
shouldShowChaptersTab() {
|
||||
return this.chapters.length > 0;
|
||||
}
|
||||
|
||||
updateWhichTabsToShow() {
|
||||
this.showVolumeTab = this.shouldShowVolumeTab();
|
||||
this.showStorylineTab = this.shouldShowStorylineTab();
|
||||
this.showChapterTab = this.shouldShowChaptersTab();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* This will update the selected tab
|
||||
*
|
||||
@ -771,10 +809,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
loadPlusMetadata(seriesId: number, libraryType: LibraryType) {
|
||||
this.isLoadingExtra = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => {
|
||||
this.isLoadingExtra = false;
|
||||
this.cdRef.markForCheck();
|
||||
if (data === null) return;
|
||||
if (data === null) {
|
||||
this.isLoadingExtra = false;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('isLoadingExtra is false')
|
||||
return;
|
||||
}
|
||||
|
||||
// Reviews
|
||||
this.reviews = [...data.reviews];
|
||||
@ -790,7 +832,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
this.hasRecommendations = this.combinedRecs.length > 0;
|
||||
|
||||
this.isLoadingExtra = false;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('isLoadingExtra is false')
|
||||
});
|
||||
}
|
||||
|
||||
@ -970,11 +1014,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
downloadSeries() {
|
||||
this.downloadService.download('series', this.series, (d) => {
|
||||
if (d) {
|
||||
this.downloadInProgress = true;
|
||||
} else {
|
||||
this.downloadInProgress = false;
|
||||
}
|
||||
this.downloadInProgress = !!d;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -220,6 +220,7 @@ export class DownloadService {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private getIdKey(entity: Chapter | Volume) {
|
||||
if (this.utilityService.isVolume(entity)) return 'volumeId';
|
||||
if (this.utilityService.isChapter(entity)) return 'chapterId';
|
||||
|
@ -1,27 +1,30 @@
|
||||
<ng-container *ngIf="currentValue > 0">
|
||||
<div [ngClass]="{'number': center}" class="indicator" *ngIf="showIcon">
|
||||
@if (currentValue > 0) {
|
||||
@if (showIcon) {
|
||||
<div [ngClass]="{'number': center}" class="indicator">
|
||||
<i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div [ngStyle]="{'width': width, 'height': height}">
|
||||
<circle-progress
|
||||
[percent]="currentValue"
|
||||
[radius]="100"
|
||||
[outerStrokeWidth]="15"
|
||||
[innerStrokeWidth]="0"
|
||||
[space] = "0"
|
||||
[backgroundPadding]="0"
|
||||
outerStrokeLinecap="butt"
|
||||
[outerStrokeColor]="outerStrokeColor"
|
||||
[innerStrokeColor]="innerStrokeColor"
|
||||
titleFontSize= "24"
|
||||
unitsFontSize= "24"
|
||||
[showSubtitle] = "false"
|
||||
[animation]="animation"
|
||||
[animationDuration]="300"
|
||||
[startFromZero]="false"
|
||||
[responsive]="true"
|
||||
[backgroundOpacity]="0.5"
|
||||
[backgroundColor]="backgroundColor"
|
||||
></circle-progress>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div [ngStyle]="{'width': width, 'height': height}">
|
||||
<circle-progress
|
||||
[percent]="currentValue"
|
||||
[radius]="100"
|
||||
[outerStrokeWidth]="15"
|
||||
[innerStrokeWidth]="0"
|
||||
[space] = "0"
|
||||
[backgroundPadding]="0"
|
||||
outerStrokeLinecap="butt"
|
||||
[outerStrokeColor]="outerStrokeColor"
|
||||
[innerStrokeColor]="innerStrokeColor"
|
||||
titleFontSize= "24"
|
||||
unitsFontSize= "24"
|
||||
[showSubtitle] = "false"
|
||||
[animation]="animation"
|
||||
[animationDuration]="300"
|
||||
[startFromZero]="false"
|
||||
[responsive]="true"
|
||||
[backgroundOpacity]="0.5"
|
||||
[backgroundColor]="backgroundColor"
|
||||
></circle-progress>
|
||||
</div>
|
||||
}
|
||||
|
@ -1,14 +1,11 @@
|
||||
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {CommonModule, NgClass, NgStyle} from "@angular/common";
|
||||
import {NgCircleProgressModule } from "ng-circle-progress";
|
||||
|
||||
@Component({
|
||||
selector: 'app-circular-loader',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgCircleProgressModule],
|
||||
// providers: [
|
||||
// importProvidersFrom(NgCircleProgressModule),
|
||||
// ],
|
||||
imports: [NgCircleProgressModule, NgStyle, NgClass],
|
||||
templateUrl: './circular-loader.component.html',
|
||||
styleUrls: ['./circular-loader.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -5,21 +5,28 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div class="mb-3" *ngIf="items.length >= 5">
|
||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
|
||||
@if (items.length >= 5) {
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center clickable" *ngFor="let item of items | filter: filterList; let i = index">
|
||||
{{item}}
|
||||
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
|
||||
</button>
|
||||
</li>
|
||||
@for(item of items | filter: filterList; track item; let i = $index) {
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center clickable">
|
||||
{{item}}
|
||||
@if (clicked !== undefined) {
|
||||
<button class="btn btn-primary" (click)="handleClick(item)">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import {Component, inject, Input} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { FilterPipe } from '../../../../_pipes/filter.pipe';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
@ -10,9 +9,11 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
||||
templateUrl: './generic-list-modal.component.html',
|
||||
styleUrls: ['./generic-list-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgIf, NgFor, FilterPipe, TranslocoDirective]
|
||||
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective]
|
||||
})
|
||||
export class GenericListModalComponent {
|
||||
private readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input() items: Array<string> = [];
|
||||
@Input() title: string = '';
|
||||
@Input() clicked: ((item: string) => void) | undefined = undefined;
|
||||
@ -25,8 +26,6 @@ export class GenericListModalComponent {
|
||||
return listItem.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
constructor(private modal: NgbActiveModal) {}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
|
@ -28,7 +28,7 @@
|
||||
<table class="table table-striped table-striped table-hover table-sm scrollable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" sortable="extension" (sort)="onSort($event)">
|
||||
<th scope="col" sortable="extension" direction="asc" (sort)="onSort($event)">
|
||||
{{t('extension-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="format" (sort)="onSort($event)">
|
||||
@ -40,6 +40,7 @@
|
||||
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
|
||||
{{t('total-files-header')}}
|
||||
</th>
|
||||
<th scope="col">{{t('download-file-for-extension-header')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -56,6 +57,16 @@
|
||||
<td>
|
||||
{{item.totalFiles | number:'1.0-0'}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-icon" style="color: var(--primary-color)" (click)="export(item.extension)" [disabled]="downloadInProgress[item.extension]">
|
||||
@if (downloadInProgress[item.extension]) {
|
||||
<div class="spinner-border spinner-border-sm" aria-hidden="true"></div>
|
||||
} @else {
|
||||
<i class="fa-solid fa-file-arrow-down" aria-hidden="true"></i>
|
||||
}
|
||||
<span class="visually-hidden">{{t('download-file-for-extension-alt"', {extension: item.extension})}}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
@ -73,6 +84,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<ng-template #modalTable>
|
||||
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
@ -16,12 +16,11 @@ import { PieDataItem } from '../../_models/pie-data-item';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe';
|
||||
import { BytesPipe } from '../../../_pipes/bytes.pipe';
|
||||
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
|
||||
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
|
||||
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {filter, tap} from "rxjs/operators";
|
||||
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component";
|
||||
import {Pagination} from "../../../_models/pagination";
|
||||
import {DownloadService} from "../../../shared/_services/download.service";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
export interface StackedBarChartDataItem {
|
||||
name: string,
|
||||
@ -34,7 +33,7 @@ export interface StackedBarChartDataItem {
|
||||
styleUrls: ['./file-breakdown-stats.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoDirective]
|
||||
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoDirective, SortableHeader]
|
||||
})
|
||||
export class FileBreakdownStatsComponent {
|
||||
|
||||
@ -42,7 +41,7 @@ export class FileBreakdownStatsComponent {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
|
||||
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>;
|
||||
@ViewChild('modalTable') modalTable!: TemplateRef<any>;
|
||||
|
||||
rawData$!: Observable<FileExtensionBreakdown>;
|
||||
files$!: Observable<Array<FileExtension>>;
|
||||
@ -55,9 +54,10 @@ export class FileBreakdownStatsComponent {
|
||||
|
||||
formControl: FormControl = new FormControl(true, []);
|
||||
|
||||
downloadInProgress: {[key: string]: boolean} = {};
|
||||
|
||||
private readonly statService = inject(StatisticsService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly ngbModal = inject(NgbModal);
|
||||
|
||||
constructor() {
|
||||
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
|
||||
@ -80,17 +80,6 @@ export class FileBreakdownStatsComponent {
|
||||
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
|
||||
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
|
||||
})));
|
||||
|
||||
// TODO: See if you can figure this out
|
||||
// this.formControl.valueChanges.pipe(filter(v => !v), takeUntilDestroyed(this.destroyRef), switchMap(_ => {
|
||||
// const ref = this.ngbModal.open(GenericTableModalComponent);
|
||||
// ref.componentInstance.title = translate('file-breakdown-stats.format-title');
|
||||
// ref.componentInstance.bodyTemplate = this.tableTemplate;
|
||||
// return ref.dismissed;
|
||||
// }, tap(_ => {
|
||||
// this.formControl.setValue(true);
|
||||
// this.cdRef.markForCheck();
|
||||
// }))).subscribe();
|
||||
}
|
||||
|
||||
onSort(evt: SortEvent<FileExtension>) {
|
||||
@ -104,4 +93,15 @@ export class FileBreakdownStatsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
export(format: string) {
|
||||
this.downloadInProgress[format] = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.statService.downloadFileBreakdown(format)
|
||||
.subscribe(() => {
|
||||
this.downloadInProgress[format] = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1513,7 +1513,8 @@
|
||||
"users-online-count": "{{num}} Users online",
|
||||
"active-events-title": "Active Events:",
|
||||
"no-data": "Not much going on here",
|
||||
"left-to-process": "Left to Process: {{leftToProcess}}"
|
||||
"left-to-process": "Left to Process: {{leftToProcess}}",
|
||||
"download-in-queue": "{{num}} downloads in Queue"
|
||||
},
|
||||
|
||||
"shortcuts-modal": {
|
||||
@ -1868,7 +1869,9 @@
|
||||
"total-size-header": "Total Size",
|
||||
"total-files-header": "Total Files",
|
||||
"not-classified": "Not Classified",
|
||||
"total-file-size-title": "Total File Size:"
|
||||
"total-file-size-title": "Total File Size:",
|
||||
"download-file-for-extension-header": "Download Report",
|
||||
"download-file-for-extension-alt": "Download files Report for {{extension}}"
|
||||
},
|
||||
|
||||
"reading-activity": {
|
||||
@ -2326,7 +2329,10 @@
|
||||
"issue-hash-num": "Issue #",
|
||||
"issue-num": "Issue",
|
||||
"chapter-num": "Chapter",
|
||||
"volume-num": "Volume"
|
||||
"volume-num": "Volume",
|
||||
"chapter-num-shorthand": "Ch {{num}}",
|
||||
"issue-num-shorthand": "#{{num}}",
|
||||
"volume-num-shorthand": "Vol {{num}}"
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -242,6 +242,10 @@
|
||||
--event-widget-item-border-color: rgba(53, 53, 53, 0.5);
|
||||
--event-widget-border-color: rgba(1, 4, 9, 0.5);
|
||||
--event-widget-info-bg-color: #b6d4fe;
|
||||
--event-widget-error-bg-color: var(--error-color);
|
||||
--event-widget-update-bg-color: var(--primary-color);
|
||||
--event-widget-activity-bg-color: var(--primary-color);
|
||||
|
||||
|
||||
/* Search */
|
||||
--search-result-text-lite-color: initial;
|
||||
|
Loading…
x
Reference in New Issue
Block a user