Event Widget Updates + Format Downloads + Scanner Work (#3024)

This commit is contained in:
Joe Milazzo 2024-06-27 16:35:50 -05:00 committed by GitHub
parent 30a8a2555f
commit a427d02ed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 971 additions and 694 deletions

View File

@ -21,7 +21,6 @@ using AutoMapper;
using EasyCaching.Core; using EasyCaching.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler; 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")); if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
await _libraryWatcher.RestartWatching(); await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id); await _taskScheduler.ScanLibrary(library.Id);
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate,
@ -292,7 +291,7 @@ public class LibraryController : BaseApiController
public async Task<ActionResult> Scan(int libraryId, bool force = false) public async Task<ActionResult> Scan(int libraryId, bool force = false)
{ {
if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId")); if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId"));
_taskScheduler.ScanLibrary(libraryId, force); await _taskScheduler.ScanLibrary(libraryId, force);
return Ok(); return Ok();
} }
@ -500,7 +499,7 @@ public class LibraryController : BaseApiController
if (originalFoldersCount != dto.Folders.Count() || typeUpdate) if (originalFoldersCount != dto.Folders.Count() || typeUpdate)
{ {
await _libraryWatcher.RestartWatching(); await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id); await _taskScheduler.ScanLibrary(library.Id);
} }
if (folderWatchingUpdate) if (folderWatchingUpdate)

View File

@ -868,6 +868,7 @@ public class OpdsController : BaseApiController
SetFeedId(feed, $"series-{series.Id}"); SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
var chapterDict = new Dictionary<int, short>();
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes) foreach (var volume in seriesDetail.Volumes)
{ {
@ -879,6 +880,7 @@ public class OpdsController : BaseApiController
var chapterDto = _mapper.Map<ChapterDto>(chapter); var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in chapter.Files) foreach (var mangaFile in chapter.Files)
{ {
chapterDict.Add(chapterId, 0);
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series, feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, apiKey, prefix, baseUrl)); chapterDto, apiKey, prefix, baseUrl));
} }
@ -892,7 +894,7 @@ public class OpdsController : BaseApiController
chapters = seriesDetail.Chapters; 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 files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
var chapterDto = _mapper.Map<ChapterDto>(chapter); var chapterDto = _mapper.Map<ChapterDto>(chapter);

View File

@ -1,5 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
@ -7,11 +10,15 @@ using API.DTOs.Statistics;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Helpers;
using API.Services; using API.Services;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
using CsvHelper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MimeTypes;
namespace API.Controllers; namespace API.Controllers;
@ -24,15 +31,18 @@ public class StatsController : BaseApiController
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IDirectoryService _directoryService;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, public StatsController(IStatisticService statService, IUnitOfWork unitOfWork,
UserManager<AppUser> userManager, ILocalizationService localizationService, ILicenseService licenseService) UserManager<AppUser> userManager, ILocalizationService localizationService,
ILicenseService licenseService, IDirectoryService directoryService)
{ {
_statService = statService; _statService = statService;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_userManager = userManager; _userManager = userManager;
_localizationService = localizationService; _localizationService = localizationService;
_licenseService = licenseService; _licenseService = licenseService;
_directoryService = directoryService;
} }
[HttpGet("user/{userId}/read")] [HttpGet("user/{userId}/read")]
@ -111,6 +121,34 @@ public class StatsController : BaseApiController
return Ok(await _statService.GetFileBreakdown()); 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> /// <summary>
/// Returns reading history events for a give or all users, broken up by day, and format /// Returns reading history events for a give or all users, broken up by day, and format

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

View File

@ -21,6 +21,7 @@ using API.DTOs.Search;
using API.DTOs.SeriesDetail; using API.DTOs.SeriesDetail;
using API.DTOs.Settings; using API.DTOs.Settings;
using API.DTOs.SideNav; using API.DTOs.SideNav;
using API.DTOs.Stats;
using API.DTOs.Theme; using API.DTOs.Theme;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
@ -326,5 +327,8 @@ public class AutoMapperProfiles : Profile
opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); opt.MapFrom(src => ReviewService.GetCharacters(src.Body)));
CreateMap<ExternalRecommendation, ExternalSeriesDto>(); CreateMap<ExternalRecommendation, ExternalSeriesDto>();
CreateMap<MangaFile, FileExtensionExportDto>();
} }
} }

View File

@ -97,7 +97,7 @@ public class Program
Task.Run(async () => Task.Run(async () =>
{ {
// Apply all migrations on startup // Apply all migrations on startup
logger.LogInformation("Running Migrations"); logger.LogInformation("Running Manual Migrations");
try try
{ {
@ -113,7 +113,7 @@ public class Program
} }
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
logger.LogInformation("Running Migrations - complete"); logger.LogInformation("Running Manual Migrations - complete");
}).GetAwaiter() }).GetAwaiter()
.GetResult(); .GetResult();
} }

View File

@ -134,9 +134,16 @@ public class ImageService : IImageService
/// <returns></returns> /// <returns></returns>
public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight) 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; return Enums.Size.Both;
@ -144,9 +151,15 @@ public class ImageService : IImageService
public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight) public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight)
{ {
try
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{ {
if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height))
{
return null;
}
} catch (Exception)
{
/* Swallow */
return null; return null;
} }
@ -166,8 +179,8 @@ public class ImageService : IImageService
} }
// Calculate scaling factors // Calculate scaling factors
var widthScaleFactor = (double)targetWidth / sourceImage.Width; var widthScaleFactor = (double) targetWidth / sourceImage.Width;
var heightScaleFactor = (double)targetHeight / sourceImage.Height; var heightScaleFactor = (double) targetHeight / sourceImage.Height;
// Check resolution quality (example thresholds) // Check resolution quality (example thresholds)
if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0) 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> /// <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) public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
{ {
var (width, height) = size.GetDimensions(); var (targetWidth, targetHeight) = size.GetDimensions();
stream.Position = 0; if (stream.CanSeek) stream.Position = 0;
using var sourceImage = Image.NewFromStream(stream); 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(); var filename = fileName + encodeFormat.GetExtension();
_directoryService.ExistOrCreate(outputDirectory); _directoryService.ExistOrCreate(outputDirectory);
try try

View File

@ -501,6 +501,7 @@ public class SeriesService : ISeriesService
StorylineChapters = storylineChapters, StorylineChapters = storylineChapters,
TotalCount = chapters.Count, TotalCount = chapters.Count,
UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages), UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages),
// TODO: See if we can get the ContinueFrom here
}; };
} }

View File

@ -5,10 +5,12 @@ using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs; using API.DTOs;
using API.DTOs.Statistics; using API.DTOs.Statistics;
using API.DTOs.Stats;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Extensions; using API.Extensions;
using API.Extensions.QueryExtensions; using API.Extensions.QueryExtensions;
using API.Helpers;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser; using API.Services.Tasks.Scanner.Parser;
using AutoMapper; using AutoMapper;
@ -35,6 +37,7 @@ public interface IStatisticService
Task UpdateServerStatistics(); Task UpdateServerStatistics();
Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds); Task<long> TimeSpentReadingForUsersAsync(IList<int> userIds, IList<int> libraryIds);
Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown(); Task<KavitaPlusMetadataBreakdownDto> GetKavitaPlusMetadataBreakdown();
Task<IEnumerable<FileExtensionExportDto>> GetFilesByExtension(string fileExtension);
} }
/// <summary> /// <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) public async Task<IEnumerable<TopReadDto>> GetTopUsers(int days)
{ {
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();

View File

@ -4,11 +4,13 @@ using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Data.Repositories;
using API.Entities.Enums; using API.Entities.Enums;
using API.Helpers.Converters; using API.Helpers.Converters;
using API.Services.Plus; using API.Services.Plus;
using API.Services.Tasks; using API.Services.Tasks;
using API.Services.Tasks.Metadata; using API.Services.Tasks.Metadata;
using API.SignalR;
using Hangfire; using Hangfire;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -22,12 +24,12 @@ public interface ITaskScheduler
Task ScheduleKavitaPlusTasks(); Task ScheduleKavitaPlusTasks();
void ScanFolder(string folderPath, string originalPath, TimeSpan delay); void ScanFolder(string folderPath, string originalPath, TimeSpan delay);
void ScanFolder(string folderPath); void ScanFolder(string folderPath);
void ScanLibrary(int libraryId, bool force = false); Task ScanLibrary(int libraryId, bool force = false);
void ScanLibraries(bool force = false); Task ScanLibraries(bool force = false);
void CleanupChapters(int[] chapterIds); void CleanupChapters(int[] chapterIds);
void RefreshMetadata(int libraryId, bool forceUpdate = true); void RefreshMetadata(int libraryId, bool forceUpdate = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); 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 AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
void CancelStatsTasks(); void CancelStatsTasks();
@ -57,6 +59,7 @@ public class TaskScheduler : ITaskScheduler
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
private readonly IExternalMetadataService _externalMetadataService; private readonly IExternalMetadataService _externalMetadataService;
private readonly ISmartCollectionSyncService _smartCollectionSyncService; private readonly ISmartCollectionSyncService _smartCollectionSyncService;
private readonly IEventHub _eventHub;
public static BackgroundJobServer Client => new (); public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan"; public const string ScanQueue = "scan";
@ -93,7 +96,7 @@ public class TaskScheduler : ITaskScheduler
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService,
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService) IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub)
{ {
_cacheService = cacheService; _cacheService = cacheService;
_logger = logger; _logger = logger;
@ -112,6 +115,7 @@ public class TaskScheduler : ITaskScheduler
_licenseService = licenseService; _licenseService = licenseService;
_externalMetadataService = externalMetadataService; _externalMetadataService = externalMetadataService;
_smartCollectionSyncService = smartCollectionSyncService; _smartCollectionSyncService = smartCollectionSyncService;
_eventHub = eventHub;
} }
public async Task ScheduleTasks() 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. /// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future.
/// </summary> /// </summary>
/// <param name="force"></param> /// <param name="force"></param>
public void ScanLibraries(bool force = false) public async Task ScanLibraries(bool force = false)
{ {
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{ {
_logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours"); _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)); 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; return;
} }
BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force)); 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)) if (HasScanTaskRunningForLibrary(libraryId))
{ {
@ -340,18 +347,18 @@ public class TaskScheduler : ITaskScheduler
} }
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{ {
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
_logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours"); _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)); BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3));
return; 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); _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 // 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) public void TurnOnScrobbling(int userId = 0)
@ -392,7 +399,7 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate)); 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)) if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue))
{ {
@ -402,7 +409,10 @@ public class TaskScheduler : ITaskScheduler
if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) if (RunningAnyTasksByMethod(ScanTasks, ScanQueue))
{ {
// BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice) // 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"); _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)); BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10));
return; return;
} }

View File

@ -150,13 +150,28 @@ public class LibraryWatcher : ILibraryWatcher
{ {
_logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); _logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType);
if (e.ChangeType != WatcherChangeTypes.Changed) return; 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) private void OnCreated(object sender, FileSystemEventArgs e)
{ {
_logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); _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> /// <summary>
@ -168,6 +183,11 @@ public class LibraryWatcher : ILibraryWatcher
var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
if (!isDirectory) return; if (!isDirectory) return;
_logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); _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)); 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 /// This is called via Hangfire to decrement the counter. Must work around a lock
/// </summary> /// </summary>
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
public void UpdateLastBufferOverflow() public static void UpdateLastBufferOverflow()
{ {
lock (Lock) lock (Lock)
{ {

View File

@ -351,14 +351,17 @@ public class ParseScannedFiles
{ {
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); 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 List<ScannedSeriesResult>();
//var processedScannedSeries = new ConcurrentBag<ScannedSeriesResult>(); //var processedScannedSeries = new ConcurrentBag<ScannedSeriesResult>();
foreach (var folderPath in folders) foreach (var folderPath in folders)
{ {
try 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); 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) foreach (var scanResult in scanResults)
{ {
await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries);

View File

@ -76,7 +76,7 @@ public enum ScanCancelReason
public class ScannerService : IScannerService public class ScannerService : IScannerService
{ {
public const string Name = "ScannerService"; 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 IUnitOfWork _unitOfWork;
private readonly ILogger<ScannerService> _logger; private readonly ILogger<ScannerService> _logger;
private readonly IMetadataService _metadataService; private readonly IMetadataService _metadataService;
@ -157,11 +157,11 @@ public class ScannerService : IScannerService
} }
// TODO: Figure out why we have the library type restriction here // 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)) 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; return;
} }
_logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder); _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)) 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; return;
} }
BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1));
@ -198,20 +198,21 @@ public class ScannerService : IScannerService
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="bypassFolderOptimizationChecks">Not Used. Scan series will always force</param> /// <param name="bypassFolderOptimizationChecks">Not Used. Scan series will always force</param>
[Queue(TaskScheduler.ScanQueue)] [Queue(TaskScheduler.ScanQueue)]
[DisableConcurrentExecution(Timeout)]
[AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true) 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 sw = Stopwatch.StartNew();
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); 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 (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 existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId});
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); 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 // 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))) 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, 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", 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))) if (folders.Any(f => _directoryService.IsDirectoryEmpty(f)))
{ {
// That way logging and UI informing is all in one place with full context // 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. " + "Either your mount has been disconnected or you are trying to delete all series in the library. " +
"Scan has be aborted. " + "Scan has be aborted. " +
"Check that your mount is connected or change the library's root folder and rescan"); "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)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries(bool forceUpdate = false) 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()) 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"); // We don't need to send SignalR event as this is a background job that user doesn't need insight into
await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed", _logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name);
$"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for {DateTime.UtcNow.AddHours(4)} UTC")); await Task.Delay(TimeSpan.FromHours(4));
BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4)); //BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4));
return; //return;
} }
await ScanLibrary(lib.Id, forceUpdate, true); await ScanLibrary(lib.Id, forceUpdate, true);
} }
_processSeries.Reset(); _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)); var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths));
if (!shouldUseLibraryScan) 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, var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths,
shouldUseLibraryScan, forceUpdate); shouldUseLibraryScan, forceUpdate);
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name);
var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>(); var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
TrackFoundSeriesAndFiles(parsedSeries, processedSeries); TrackFoundSeriesAndFiles(parsedSeries, processedSeries);
// We need to remove any keys where there is no actual parser info // 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); var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime);
UpdateLastScanned(library); UpdateLastScanned(library);
_unitOfWork.LibraryRepository.Update(library); _unitOfWork.LibraryRepository.Update(library);
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name);
if (await _unitOfWork.CommitAsync()) if (await _unitOfWork.CommitAsync())
{ {
if (isSingleScan) if (isSingleScan)
@ -563,6 +569,7 @@ public class ScannerService : IScannerService
totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name); totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name);
} }
_logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name);
await RemoveSeriesNotFound(parsedSeries, library); await RemoveSeriesNotFound(parsedSeries, library);
} }
else else

View File

@ -345,6 +345,7 @@ public static class MessageFactory
EventType = ProgressEventType.Single, EventType = ProgressEventType.Single,
Body = new Body = new
{ {
Name = Error,
Title = title, Title = title,
SubTitle = subtitle, SubTitle = subtitle,
} }
@ -362,6 +363,7 @@ public static class MessageFactory
EventType = ProgressEventType.Single, EventType = ProgressEventType.Single,
Body = new Body = new
{ {
Name = Info,
Title = title, Title = title,
SubTitle = subtitle, SubTitle = subtitle,
} }

View File

@ -427,8 +427,8 @@ public class Startup
catch (Exception) catch (Exception)
{ {
/* Swallow Exception */ /* Swallow Exception */
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
} }
Console.WriteLine($"Kavita - v{BuildInfo.Version}");
}); });
logger.LogInformation("Starting with base url as {BaseUrl}", basePath); logger.LogInformation("Starting with base url as {BaseUrl}", basePath);

View File

@ -2,7 +2,7 @@
"TokenKey": "super secret unguessable key that is longer because we require it", "TokenKey": "super secret unguessable key that is longer because we require it",
"Port": 5000, "Port": 5000,
"IpAddresses": "0.0.0.0,::", "IpAddresses": "0.0.0.0,::",
"BaseUrl": "/tes/", "BaseUrl": "/",
"Cache": 75, "Cache": 75,
"AllowIFraming": false "AllowIFraming": false
} }

View File

@ -1,9 +1,9 @@
import { HttpClient } from '@angular/common/http'; 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 { environment } from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; 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 { MangaFormatPipe } from '../_pipes/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads'; import { TopUserRead } from '../statistics/_models/top-reads';
@ -15,6 +15,10 @@ import { MangaFormat } from '../_models/manga-format';
import { TextResonse } from '../_types/text-response'; import { TextResonse } from '../_types/text-response';
import {TranslocoService} from "@ngneat/transloco"; import {TranslocoService} from "@ngneat/transloco";
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; 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 export enum DayOfWeek
{ {
@ -37,7 +41,7 @@ export class StatisticsService {
publicationStatusPipe = new PublicationStatusPipe(this.translocoService); publicationStatusPipe = new PublicationStatusPipe(this.translocoService);
mangaFormatPipe = new MangaFormatPipe(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> = []) { getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object // TODO: Convert to httpParams object
@ -109,6 +113,20 @@ export class StatisticsService {
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown'); 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) { getReadCountByDay(userId: number = 0, days: number = 0) {
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days); return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
} }

View File

@ -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 const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
export type SortColumn<T> = keyof T | ''; export type SortColumn<T> = keyof T | '';
@ -11,6 +11,7 @@ export interface SortEvent<T> {
} }
@Directive({ @Directive({
// eslint-disable-next-line @angular-eslint/directive-selector
selector: 'th[sortable]', selector: 'th[sortable]',
host: { host: {
'[class.asc]': 'direction === "asc"', '[class.asc]': 'direction === "asc"',
@ -29,4 +30,4 @@ export class SortableHeader<T> {
this.direction = rotate[this.direction]; this.direction = rotate[this.direction];
this.sort.emit({ column: this.sortable, direction: this.direction }); this.sort.emit({ column: this.sortable, direction: this.direction });
} }
} }

View File

@ -4,27 +4,33 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
<span>{{libraryName}}</span> <span>{{libraryName}}</span>
</h2> </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-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations> <app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading> <app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
<app-card-detail-layout *ngIf="filter" @if (filter) {
[isLoading]="loadingSeries" <app-card-detail-layout
[items]="series" [isLoading]="loadingSeries"
[pagination]="pagination" [items]="series"
[filterSettings]="filterSettings" [pagination]="pagination"
[trackByIdentity]="trackByIdentity" [filterSettings]="filterSettings"
[filterOpen]="filterOpen" [trackByIdentity]="trackByIdentity"
[jumpBarKeys]="jumpKeys" [filterOpen]="filterOpen"
[refresh]="refresh" [jumpBarKeys]="jumpKeys"
(applyFilter)="updateFilter($event)" [refresh]="refresh"
> (applyFilter)="updateFilter($event)"
<ng-template #cardItem let-item let-position="idx"> >
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()" <ng-template #cardItem let-item let-position="idx">
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" <app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card> (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
</ng-template> [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
</app-card-detail-layout> </ng-template>
</app-card-detail-layout>
}
</ng-container> </ng-container>

View File

@ -33,18 +33,18 @@ import {SentenceCasePipe} from '../_pipes/sentence-case.pipe';
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component'; import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
import {SeriesCardComponent} from '../cards/series-card/series-card.component'; import {SeriesCardComponent} from '../cards/series-card/series-card.component';
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.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 {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {MetadataService} from "../_services/metadata.service";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
import {FilterField} from "../_models/metadata/v2/filter-field"; import {FilterField} from "../_models/metadata/v2/filter-field";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component"; import {LoadingComponent} from "../shared/loading/loading.component";
import {debounceTime, ReplaySubject, tap} from "rxjs";
@Component({ @Component({
selector: 'app-library-detail', selector: 'app-library-detail',
@ -52,14 +52,25 @@ import {LoadingComponent} from "../shared/loading/loading.component";
styleUrls: ['./library-detail.component.scss'], styleUrls: ['./library-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent,
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent] CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
}) })
export class LibraryDetailComponent implements OnInit { export class LibraryDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly metadataService = inject(MetadataService);
private readonly cdRef = inject(ChangeDetectorRef); 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; libraryId!: number;
libraryName = ''; libraryName = '';
@ -82,6 +93,8 @@ export class LibraryDetailComponent implements OnInit {
]; ];
active = this.tabs[0]; active = this.tabs[0];
loadPageSource = new ReplaySubject(1);
loadPage$ = this.loadPageSource.asObservable();
bulkActionCallback = async (action: ActionItem<any>, data: any) => { bulkActionCallback = async (action: ActionItem<any>, data: any) => {
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series'); 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, constructor() {
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
const routeId = this.route.snapshot.paramMap.get('libraryId'); const routeId = this.route.snapshot.paramMap.get('libraryId');
if (routeId === null) { if (routeId === null) {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/home');
@ -180,6 +191,8 @@ export class LibraryDetailComponent implements OnInit {
this.filterSettings.presetsV2 = this.filter; this.filterSettings.presetsV2 = this.filter;
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe();
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
@ -191,7 +204,7 @@ export class LibraryDetailComponent implements OnInit {
const seriesAdded = event.payload as SeriesAddedEvent; const seriesAdded = event.payload as SeriesAddedEvent;
if (seriesAdded.libraryId !== this.libraryId) return; if (seriesAdded.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
this.loadPage(); this.loadPageSource.next(true);
return; return;
} }
this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => { this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => {
@ -211,7 +224,7 @@ export class LibraryDetailComponent implements OnInit {
const seriesRemoved = event.payload as SeriesRemovedEvent; const seriesRemoved = event.payload as SeriesRemovedEvent;
if (seriesRemoved.libraryId !== this.libraryId) return; if (seriesRemoved.libraryId !== this.libraryId) return;
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { 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; return;
} }
@ -286,12 +299,12 @@ export class LibraryDetailComponent implements OnInit {
this.filter = data.filterV2; this.filter = data.filterV2;
if (data.isFirst) { if (data.isFirst) {
this.loadPage(); this.loadPageSource.next(true);
return; return;
} }
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => { this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
this.loadPage(); this.loadPageSource.next(true);
}); });
} }

View File

@ -1,139 +1,118 @@
<ng-container *transloco="let t; read: 'events-widget'"> <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> <ng-template #popContent>
<ul class="list-group list-group-flush dark-menu"> <ul class="list-group list-group-flush dark-menu">
<ng-container *ngIf="errors$ | async as errors"> @if(errors$ | async; as errors) {
<ng-container *ngIf="infos$ | async as infos"> @if(infos$ | async; as infos) {
<li class="list-group-item dark-menu-item clickable" *ngIf="errors.length > 0 || infos.length > 0" (click)="clearAllErrorOrInfos()"> @if (errors.length > 0 || infos.length > 0) {
{{t('dismiss-all')}} <li class="list-group-item dark-menu-item clickable" (click)="clearAllErrorOrInfos()">
</li> {{t('dismiss-all')}}
</ng-container> </li>
</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>
} }
<!-- Progress Events--> <!-- Progress Events-->
<ng-container *ngIf="progressEvents$ | async as progressUpdates"> @if (progressEvents$ | async; as progressUpdates) {
<ng-container *ngFor="let message of progressUpdates"> @for (message of progressUpdates; track message) {
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent"> @if (message.progress === 'indeterminate' || message.progress === 'none') {
<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>
<li class="list-group-item dark-menu-item"> <li class="list-group-item dark-menu-item">
<div class="h6 mb-1">{{message.title}}</div> <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="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(message.body.progress) + '%'}}</div> <div class="col-2">{{prettyPrintProgress(message.body.progress) + '%'}}</div>
<div class="col-10 progress" style="height: 5px;"> <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>
</div> </div>
</li> </li>
</ng-template> }
</ng-container> }
</ng-container> }
<!-- Single updates (Informational/Update available)--> <!-- Single updates (Informational/Update available)-->
<ng-container *ngIf="singleUpdates$ | async as singleUpdates"> @if (singleUpdates$ | async; as singleUpdates) {
<ng-container *ngFor="let singleUpdate of singleUpdates"> @for(singleUpdate of singleUpdates; track singleUpdate) {
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name === EVENTS.UpdateAvailable" (click)="handleUpdateAvailableClick(singleUpdate)"> @if (singleUpdate.name === EVENTS.UpdateAvailable) {
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}} <li class="list-group-item dark-menu-item update-available" (click)="handleUpdateAvailableClick(singleUpdate)">
</li> <i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name !== EVENTS.UpdateAvailable"> </li>
<div>{{singleUpdate.title}}</div> } @else {
<div class="accent-text" *ngIf="singleUpdate.subTitle !== ''">{{singleUpdate.subTitle}}</div> <li class="list-group-item dark-menu-item update-available">
</li> <div>{{singleUpdate.title}}</div>
</ng-container> @if (singleUpdate.subTitle !== '') {
</ng-container> <div class="accent-text">{{singleUpdate.subTitle}}</div>
}
</li>
}
}
}
<!-- Active Downloads by the user--> <!-- Active Downloads by the user-->
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads"> @if (downloadService.activeDownloads$ | async; as activeDownloads) {
<ng-container *ngFor="let download of activeDownloads"> @for(download of activeDownloads; track download) {
<li class="list-group-item dark-menu-item"> <li class="list-group-item dark-menu-item">
<div class="h6 mb-1">{{t('downloading-item', {item: download.entityType | sentenceCase})}}</div> <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="progress-container row g-0 align-items-center">
<div class="col-2">{{download.progress}}%</div> <div class="col-2">{{download.progress}}%</div>
<div class="col-10 progress" style="height: 5px;"> <div class="col-10 progress" style="height: 5px;">
@ -141,57 +120,49 @@
</div> </div>
</div> </div>
</li> </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 --> <!-- Errors -->
<ng-container *ngIf="errors$ | async as errors"> @if (errors$ | async; as errors) {
<ng-container *ngFor="let error of errors"> @for (error of errors; track error) {
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMore(error)"> <li class="list-group-item dark-menu-item error" role="alert" (click)="seeMore(error)">
<div> <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 class="accent-text mb-1">{{t('more-info')}}</div>
</div> </div>
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(error, $event)"></button> <button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(error, $event)"></button>
</li> </li>
</ng-container> }
</ng-container> }
<!-- Infos --> <!-- Infos -->
<ng-container *ngIf="infos$ | async as infos"> @if (infos$ | async; as infos) {
<ng-container *ngFor="let info of infos"> @for (info of infos; track info) {
<li class="list-group-item dark-menu-item info" role="alert" (click)="seeMore(info)"> <li class="list-group-item dark-menu-item info" role="alert" (click)="seeMore(info)">
<div> <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 class="accent-text mb-1">{{t('more-info')}}</div>
</div> </div>
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(info, $event)"></button> <button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(info, $event)"></button>
</li> </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"> @if (downloadService.activeDownloads$ | async; as activeDownloads) {
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li> @if (errors$ | async; as errors) {
</ng-container> @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> </ul>
</ng-template> </ng-template>
</ng-container> }
</ng-container> </ng-container>

View File

@ -14,6 +14,26 @@
border-bottom-color: transparent; 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 { ::ng-deep .nav-events {
.popover-body { .popover-body {
@ -56,67 +76,56 @@
.btn-icon { .btn-icon {
color: white; color: var(--event-widget-text-color);
} }
.colored {
background-color: var(--primary-color);
border-radius: 60px;
}
.colored-error { .dark-menu-item {
background-color: var(--error-color) !important; &.update-available {
border-radius: 60px;
}
.colored-info {
background-color: var(--event-widget-info-bg-color) !important;
border-radius: 60px;
}
.update-available {
cursor: pointer; cursor: pointer;
i.fa { i.fa {
color: var(--primary-color) !important; color: var(--primary-color) !important;
} }
color: var(--primary-color); color: var(--primary-color);
} }
.error { &.error {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
.h6 { .h6 {
color: var(--error-color); color: var(--event-widget-error-bg-color);
} }
i.fa { i.fa {
color: var(--primary-color) !important; color: var(--primary-color) !important;
} }
.btn-close { .btn-close {
top: 5px; top: 5px;
right: 10px; right: 10px;
font-size: 11px; font-size: 11px;
position: absolute; position: absolute;
} }
} }
.info { &.info {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
.h6 { .h6 {
color: var(--event-widget-info-bg-color); color: var(--event-widget-info-bg-color);
} }
i.fa { i.fa {
color: var(--primary-color) !important; color: var(--primary-color) !important;
} }
.btn-close { .btn-close {
top: 10px; top: 10px;
right: 10px; right: 10px;
font-size: 11px; font-size: 11px;
position: absolute; position: absolute;
} }
}
} }

View File

@ -25,7 +25,7 @@ import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hu
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe'; import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe';
import { CircularLoaderComponent } from '../../../shared/circular-loader/circular-loader.component'; 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"; import {TranslocoDirective} from "@ngneat/transloco";
@Component({ @Component({
@ -34,12 +34,20 @@ import {TranslocoDirective} from "@ngneat/transloco";
styleUrls: ['./events-widget.component.scss'], styleUrls: ['./events-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, 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 { 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); private readonly destroyRef = inject(DestroyRef);
@Input({required: true}) user!: User;
isAdmin$: Observable<boolean> = of(false); isAdmin$: Observable<boolean> = of(false);
/** /**
@ -60,17 +68,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
private updateNotificationModalRef: NgbModalRef | null = null; private updateNotificationModalRef: NgbModalRef | null = null;
activeEvents: number = 0; activeEvents: number = 0;
/**
* Intercepts from Single Updates to show an extra indicator to the user
*/
updateAvailable: boolean = false;
debugMode: boolean = false; debugMode: boolean = false;
protected readonly EVENTS = EVENTS; 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 { ngOnDestroy(): void {
this.progressEventsSource.complete(); this.progressEventsSource.complete();
@ -115,6 +121,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
values.push(message); values.push(message);
this.singleUpdateSource.next(values); this.singleUpdateSource.next(values);
this.activeEvents += 1; this.activeEvents += 1;
if (event.payload.name === EVENTS.UpdateAvailable) {
this.updateAvailable = true;
}
this.cdRef.markForCheck(); this.cdRef.markForCheck();
break; break;
case 'started': case 'started':

View File

@ -1,346 +1,393 @@
<ng-container *transloco="let t; read: 'series-detail'"> <ng-container *transloco="let t; read: 'series-detail'">
<div #companionBar> <div #companionBar>
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer"> @if (series) {
<ng-container title> <app-side-nav-companion-bar [hasExtras]="true" [extraDrawer]="extrasDrawer">
<h2 class="title text-break"> <ng-container title>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables> <h2 class="title text-break">
<span>{{series.name}} <app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
@if(isLoadingExtra || isLoading) { <span>{{series.name}}
<div class="spinner-border spinner-border-sm text-primary" role="status"> @if(isLoadingExtra || isLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">loading...</span> <span class="visually-hidden">loading...</span>
</div> </div>
} }
</span> </span>
</h2> </h2>
</ng-container> </ng-container>
<ng-container subtitle *ngIf="series.localizedName !== series.name"> @if (series.localizedName !== series.name) {
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6> <ng-container subtitle>
</ng-container> <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> <input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
<div style="margin-top: 56px"> <label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
<div class="offcanvas-header"> </div>
<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>
</div> </div>
</div> </div>
</div> </form>
</form> </div>
</div> </div>
</div> </ng-template>
</ng-template>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
}
</div> </div>
<app-bulk-operations [actionCallback]="bulkActionCallback" [topOffset]="56"></app-bulk-operations> <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> <app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
<div class="row mb-0 mb-xl-3 info-container"> @if (series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial) {
<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="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount"> <ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge> </div>
<div class="under-image">
{{t('continue-from', {title: ContinuePointTitle})}}
</div>
}
</div> </div>
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image> <div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
<ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial"> <div class="row g-0">
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read"> <div class="col-auto">
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar> <div class="btn-group">
</div> <button type="button" class="btn btn-primary" (click)="read()">
<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()">
<span> <span>
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i> <i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span> <span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue') : t('read')}}</span>
</span> </span>
</button> </button>
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')"> <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> <button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu> <div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read(true)"> <button ngbDropdownItem (click)="read(true)">
<span> <span>
<i class="fa fa-glasses" aria-hidden="true"></i> <i class="fa fa-glasses" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span> <span class="read-btn--text">&nbsp;{{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
</span> </span>
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="col-auto ms-2"> <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')}}"> <button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
<span> <span>
<i class="{{isWantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i> <i class="{{isWantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i>
</span> </span>
</button> </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>
</div> </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>
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole"> @if (seriesMetadata) {
@if (download$ | async; as download) { <div class="mt-2">
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null"> <app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
<ng-container *ngIf="download !== null; else notDownloading"> [libraryType]="libraryType" [ratings]="ratings"
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> [hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
<span class="visually-hidden">{{t('downloading-status')}}</span> </div>
</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>
</div> </div>
@if (seriesMetadata) { <div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
<div class="mt-2"> <app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series" iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
[libraryType]="libraryType" [ratings]="ratings" [clickableTitle]="true" (sectionClick)="openReviewModal()">
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail> <ng-template #carouselItem let-item let-position="idx">
</div> <app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
} </ng-template>
</app-carousel-reel>
</div>
</div> </div>
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}"> @if (series) {
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')" <ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="true" (navChange)="onNavChange($event)">
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>
<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)"> @switch (renderMode) {
<li [ngbNavItem]="TabID.Storyline" *ngIf="ShowStorylineTab"> @case (PageLayoutMode.Cards) {
<a ngbNavLink>{{t('storyline-tab')}}</a> <div class="card-container row g-0" #container>
<ng-template ngbNavContent> @for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1"> @if (item.isChapter) {
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else storylineListLayout"> <ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
<div class="card-container row g-0" #container> } @else {
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity"> <ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
{{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 [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
</ng-container> </div>
</ng-container> }
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container> @case (PageLayoutMode.List) {
</div> @for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
</ng-container> @if (item.isChapter) {
<ng-template #storylineListLayout> <ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity"> } @else {
<ng-container [ngSwitch]="item.isChapter"> <ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
<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> }
</virtual-scroller>
</ng-template> </ng-template>
</virtual-scroller> </li>
</ng-template> }
</li>
<li [ngbNavItem]="TabID.Volumes" *ngIf="ShowVolumeTab">
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a> @if (showVolumeTab) {
<ng-template ngbNavContent> <li [ngbNavItem]="TabID.Volumes">
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1"> <a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else volumeListLayout"> <ng-template ngbNavContent>
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity"> <virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
</ng-container> @switch (renderMode) {
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container> @case (PageLayoutMode.Cards) {
</div> <div class="card-container row g-0" #container>
</ng-container> @for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
<ng-template #volumeListLayout> <ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
<ng-container *ngFor="let volume of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity"> }
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: volume}"></ng-container> <ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
</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> </ng-template>
</virtual-scroller> </li>
</ng-template> }
</li>
<li [ngbNavItem]="TabID.Chapters" *ngIf="ShowChaptersTab"> @if (showChapterTab) {
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a> <li [ngbNavItem]="TabID.Chapters">
<ng-template ngbNavContent> <a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1"> <ng-template ngbNavContent>
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else chapterListLayout"> <virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
<div class="card-container row g-0" #container> @switch (renderMode) {
<div *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity"> @case (PageLayoutMode.Cards) {
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container> <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> </div>
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container> </virtual-scroller>
</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>
</ng-template> </ng-template>
</virtual-scroller> </li>
</ng-template> }
</li>
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials"> @if (hasRecommendations) {
<a ngbNavLink>{{t('specials-tab')}}</a> <li [ngbNavItem]="TabID.Recommendations">
<ng-template ngbNavContent> <a ngbNavLink>{{t('recommendations-tab')}}</a>
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1"> <ng-template ngbNavContent>
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else specialListLayout"> <virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
<div class="card-container row g-0" #container> @switch (renderMode) {
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity"> @case (PageLayoutMode.Cards) {
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container> <div class="card-container row g-0" #container>
</ng-container> @for(item of scroll.viewPortItems; let idx = $index; track idx) {
</div> @if (!item.hasOwnProperty('coverUrl')) {
</ng-container> <app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
<ng-template #specialListLayout> } @else {
<ng-container *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity"> <app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container> }
</ng-container> }
</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> </ng-template>
</virtual-scroller> </li>
</ng-template> }
</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"> </ul>
<a ngbNavLink>{{t('recommendations-tab')}}</a> <div [ngbNavOutlet]="nav"></div>
<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>
</virtual-scroller> <app-loading [loading]="isLoading"></app-loading>
</ng-template> </div>
</li> <ng-template #estimatedNextCard let-tabId="tabId">
</ul> @if (nextExpectedChapter) {
<div [ngbNavOutlet]="nav"></div> @switch (tabId) {
</ng-container> @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-container>
<ng-template #nonSpecialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength"> <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)" @if (!item.isSpecial) {
[imageUrl]="imageService.getChapterCoverImage(item.id)" <app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions" [imageUrl]="imageService.getChapterCoverImage(item.id)"
[count]="item.files.length" [read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)" [count]="item.files.length"
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"> (selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
</app-card-item> [selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>
}
</ng-template> </ng-template>
<ng-template #nonChapterVolumeCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength"> <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)" @if (item.number !== LooseLeafOrSpecialNumber) {
[imageUrl]="imageService.getVolumeCoverImage(item.id)" <app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions" [imageUrl]="imageService.getVolumeCoverImage(item.id)"
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)" [read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true"> (selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
</app-card-item> [selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
</app-card-item>
}
</ng-template> </ng-template>
<ng-template #specialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength"> <ng-template #specialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
@ -354,27 +401,32 @@
</ng-template> </ng-template>
<ng-template #nonSpecialChapterListItem let-item> <ng-template #nonSpecialChapterListItem let-item>
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId" @if (!item.isSpecial) {
[seriesName]="series.name" [entity]="item" *ngIf="!item.isSpecial" <app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight="" [seriesName]="series.name" [entity]="item"
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)" [actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[blur]="user?.preferences?.blurUnreadSummaries || false"> [pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
<ng-container title> [blur]="user?.preferences?.blurUnreadSummaries || false">
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title> <ng-container title>
</ng-container> <app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</app-list-item> </ng-container>
</app-list-item>
}
</ng-template> </ng-template>
<ng-template #nonSpecialVolumeListItem let-item> <ng-template #nonSpecialVolumeListItem let-item>
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId" @if (item.number !== LooseLeafOrSpecialNumber) {
[seriesName]="series.name" [entity]="item" *ngIf="item.number !== LooseLeafOrSpecialNumber" <app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight="" [seriesName]="series.name" [entity]="item"
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)" [actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[blur]="user?.preferences?.blurUnreadSummaries || false"> [pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
<ng-container title> [blur]="user?.preferences?.blurUnreadSummaries || false">
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title> <ng-container title>
</ng-container> <app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</app-list-item> </ng-container>
</app-list-item>
}
</ng-template><ng-template #specialChapterListItem let-item> </ng-template><ng-template #specialChapterListItem let-item>
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId" <app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"

View File

@ -104,7 +104,7 @@ import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component';
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; } 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 {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {ExternalSeries} from "../../../_models/series-detail/external-series"; import {ExternalSeries} from "../../../_models/series-detail/external-series";
import { import {
@ -282,6 +282,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}); });
user: User | undefined; user: User | undefined;
showVolumeTab = true;
showStorylineTab = true;
showChapterTab = true;
/** /**
* This is the download we get from download service. * 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() { get UseBookLogic() {
return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel; return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel;
} }
@ -380,26 +361,43 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
if (!this.currentlyReadingChapter.isSpecial) { if (!this.currentlyReadingChapter.isSpecial) {
const vol = this.volumes.filter(v => v.id === this.currentlyReadingChapter?.volumeId); 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 // This is a lone chapter
if (vol.length === 0) { if (vol.length === 0) {
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) { if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
return this.currentlyReadingChapter.titleName; 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) { 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; return this.currentlyReadingChapter.title;
} }
constructor(@Inject(DOCUMENT) private document: Document) { constructor(@Inject(DOCUMENT) private document: Document) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.subscribe(user => {
if (user) { if (user) {
this.user = user; this.user = user;
this.isAdmin = this.accountService.hasAdminRole(user); this.isAdmin = this.accountService.hasAdminRole(user);
@ -415,6 +413,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.scrollService.setScrollContainer(this.scrollingBlock); this.scrollService.setScrollContainer(this.scrollingBlock);
} }
debugLog(message: string) {
console.log(message);
}
ngOnInit(): void { ngOnInit(): void {
const routeId = this.route.snapshot.paramMap.get('seriesId'); const routeId = this.route.snapshot.paramMap.get('seriesId');
@ -424,7 +426,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return; return;
} }
// Setup the download in progress // Set up the download in progress
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
return this.downloadService.mapToEntityType(events, this.series); 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.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.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.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) => { 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.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)),
...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)), ...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)),
]; ];
if (this.relations.length > 0) { if (this.relations.length > 0) {
this.hasRelations = true; this.hasRelations = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -690,7 +691,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/home');
return of(null); return of(null);
})).subscribe(detail => { })).subscribe(detail => {
if (detail == null) return; if (detail == null) {
this.router.navigateByUrl('/home');
return;
}
this.unreadCount = detail.unreadCount; this.unreadCount = detail.unreadCount;
this.totalCount = detail.totalCount; this.totalCount = detail.totalCount;
@ -700,6 +705,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.chapters = detail.chapters; this.chapters = detail.chapters;
this.volumes = detail.volumes; this.volumes = detail.volumes;
this.storyChapters = detail.storylineChapters; this.storyChapters = detail.storylineChapters;
this.storylineItems = []; this.storylineItems = [];
const v = this.volumes.map(v => { const v = this.volumes.map(v => {
return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem; return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem;
@ -710,10 +716,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}); });
this.storylineItems.push(...c); this.storylineItems.push(...c);
this.updateWhichTabsToShow();
this.updateSelectedTab(); this.updateSelectedTab();
this.isLoading = false; this.isLoading = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
console.log('isLoading is now false')
}); });
}, err => { }, err => {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/home');
@ -724,6 +733,35 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return {series, relation} as RelatedSeriesPair; 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 * This will update the selected tab
* *
@ -771,10 +809,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
loadPlusMetadata(seriesId: number, libraryType: LibraryType) { loadPlusMetadata(seriesId: number, libraryType: LibraryType) {
this.isLoadingExtra = true; this.isLoadingExtra = true;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => { this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => {
this.isLoadingExtra = false; if (data === null) {
this.cdRef.markForCheck(); this.isLoadingExtra = false;
if (data === null) return; this.cdRef.markForCheck();
console.log('isLoadingExtra is false')
return;
}
// Reviews // Reviews
this.reviews = [...data.reviews]; this.reviews = [...data.reviews];
@ -790,7 +832,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.hasRecommendations = this.combinedRecs.length > 0; this.hasRecommendations = this.combinedRecs.length > 0;
this.isLoadingExtra = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
console.log('isLoadingExtra is false')
}); });
} }
@ -970,11 +1014,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
downloadSeries() { downloadSeries() {
this.downloadService.download('series', this.series, (d) => { this.downloadService.download('series', this.series, (d) => {
if (d) { this.downloadInProgress = !!d;
this.downloadInProgress = true;
} else {
this.downloadInProgress = false;
}
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }

View File

@ -220,6 +220,7 @@ export class DownloadService {
); );
} }
private getIdKey(entity: Chapter | Volume) { private getIdKey(entity: Chapter | Volume) {
if (this.utilityService.isVolume(entity)) return 'volumeId'; if (this.utilityService.isVolume(entity)) return 'volumeId';
if (this.utilityService.isChapter(entity)) return 'chapterId'; if (this.utilityService.isChapter(entity)) return 'chapterId';

View File

@ -1,27 +1,30 @@
<ng-container *ngIf="currentValue > 0"> @if (currentValue > 0) {
<div [ngClass]="{'number': center}" class="indicator" *ngIf="showIcon"> @if (showIcon) {
<div [ngClass]="{'number': center}" class="indicator">
<i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i> <i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i>
</div> </div>
<div [ngStyle]="{'width': width, 'height': height}"> }
<circle-progress
[percent]="currentValue" <div [ngStyle]="{'width': width, 'height': height}">
[radius]="100" <circle-progress
[outerStrokeWidth]="15" [percent]="currentValue"
[innerStrokeWidth]="0" [radius]="100"
[space] = "0" [outerStrokeWidth]="15"
[backgroundPadding]="0" [innerStrokeWidth]="0"
outerStrokeLinecap="butt" [space] = "0"
[outerStrokeColor]="outerStrokeColor" [backgroundPadding]="0"
[innerStrokeColor]="innerStrokeColor" outerStrokeLinecap="butt"
titleFontSize= "24" [outerStrokeColor]="outerStrokeColor"
unitsFontSize= "24" [innerStrokeColor]="innerStrokeColor"
[showSubtitle] = "false" titleFontSize= "24"
[animation]="animation" unitsFontSize= "24"
[animationDuration]="300" [showSubtitle] = "false"
[startFromZero]="false" [animation]="animation"
[responsive]="true" [animationDuration]="300"
[backgroundOpacity]="0.5" [startFromZero]="false"
[backgroundColor]="backgroundColor" [responsive]="true"
></circle-progress> [backgroundOpacity]="0.5"
</div> [backgroundColor]="backgroundColor"
</ng-container> ></circle-progress>
</div>
}

View File

@ -1,14 +1,11 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; 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"; import {NgCircleProgressModule } from "ng-circle-progress";
@Component({ @Component({
selector: 'app-circular-loader', selector: 'app-circular-loader',
standalone: true, standalone: true,
imports: [CommonModule, NgCircleProgressModule], imports: [NgCircleProgressModule, NgStyle, NgClass],
// providers: [
// importProvidersFrom(NgCircleProgressModule),
// ],
templateUrl: './circular-loader.component.html', templateUrl: './circular-loader.component.html',
styleUrls: ['./circular-loader.component.scss'], styleUrls: ['./circular-loader.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -5,21 +5,28 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form style="width: 100%" [formGroup]="listForm"> <form style="width: 100%" [formGroup]="listForm">
<div class="mb-3" *ngIf="items.length >= 5"> @if (items.length >= 5) {
<label for="filter" class="form-label">{{t('filter')}}</label> <div class="mb-3">
<div class="input-group"> <label for="filter" class="form-label">{{t('filter')}}</label>
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input"> <div class="input-group">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button> <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>
</div> }
<ul class="list-group"> <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"> @for(item of items | filter: filterList; track item; let i = $index) {
{{item}} <li class="list-group-item d-flex justify-content-between align-items-center clickable">
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)"> {{item}}
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i> @if (clicked !== undefined) {
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span> <button class="btn btn-primary" (click)="handleClick(item)">
</button> <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
</li> <span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
</button>
}
</li>
}
</ul> </ul>
</form> </form>
</div> </div>

View File

@ -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 { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { FilterPipe } from '../../../../_pipes/filter.pipe'; import { FilterPipe } from '../../../../_pipes/filter.pipe';
import { NgIf, NgFor } from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco"; import {TranslocoDirective} from "@ngneat/transloco";
@Component({ @Component({
@ -10,9 +9,11 @@ import {TranslocoDirective} from "@ngneat/transloco";
templateUrl: './generic-list-modal.component.html', templateUrl: './generic-list-modal.component.html',
styleUrls: ['./generic-list-modal.component.scss'], styleUrls: ['./generic-list-modal.component.scss'],
standalone: true, standalone: true,
imports: [ReactiveFormsModule, NgIf, NgFor, FilterPipe, TranslocoDirective] imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective]
}) })
export class GenericListModalComponent { export class GenericListModalComponent {
private readonly modal = inject(NgbActiveModal);
@Input() items: Array<string> = []; @Input() items: Array<string> = [];
@Input() title: string = ''; @Input() title: string = '';
@Input() clicked: ((item: string) => void) | undefined = undefined; @Input() clicked: ((item: string) => void) | undefined = undefined;
@ -25,8 +26,6 @@ export class GenericListModalComponent {
return listItem.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0; return listItem.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
} }
constructor(private modal: NgbActiveModal) {}
close() { close() {
this.modal.close(); this.modal.close();
} }

View File

@ -28,7 +28,7 @@
<table class="table table-striped table-striped table-hover table-sm scrollable"> <table class="table table-striped table-striped table-hover table-sm scrollable">
<thead> <thead>
<tr> <tr>
<th scope="col" sortable="extension" (sort)="onSort($event)"> <th scope="col" sortable="extension" direction="asc" (sort)="onSort($event)">
{{t('extension-header')}} {{t('extension-header')}}
</th> </th>
<th scope="col" sortable="format" (sort)="onSort($event)"> <th scope="col" sortable="format" (sort)="onSort($event)">
@ -40,6 +40,7 @@
<th scope="col" sortable="totalFiles" (sort)="onSort($event)"> <th scope="col" sortable="totalFiles" (sort)="onSort($event)">
{{t('total-files-header')}} {{t('total-files-header')}}
</th> </th>
<th scope="col">{{t('download-file-for-extension-header')}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -56,6 +57,16 @@
<td> <td>
{{item.totalFiles | number:'1.0-0'}} {{item.totalFiles | number:'1.0-0'}}
</td> </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> </tr>
</tbody> </tbody>
<tfoot> <tfoot>
@ -73,6 +84,8 @@
</div> </div>
<ng-template #modalTable>
</ng-template>
</ng-container> </ng-container>

View File

@ -16,12 +16,11 @@ import { PieDataItem } from '../../_models/pie-data-item';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe'; import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe';
import { BytesPipe } from '../../../_pipes/bytes.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 { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {filter, tap} from "rxjs/operators"; import {Pagination} from "../../../_models/pagination";
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component"; import {DownloadService} from "../../../shared/_services/download.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
export interface StackedBarChartDataItem { export interface StackedBarChartDataItem {
name: string, name: string,
@ -34,7 +33,7 @@ export interface StackedBarChartDataItem {
styleUrls: ['./file-breakdown-stats.component.scss'], styleUrls: ['./file-breakdown-stats.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, 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 { export class FileBreakdownStatsComponent {
@ -42,7 +41,7 @@ export class FileBreakdownStatsComponent {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>; @ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>; @ViewChild('modalTable') modalTable!: TemplateRef<any>;
rawData$!: Observable<FileExtensionBreakdown>; rawData$!: Observable<FileExtensionBreakdown>;
files$!: Observable<Array<FileExtension>>; files$!: Observable<Array<FileExtension>>;
@ -55,9 +54,10 @@ export class FileBreakdownStatsComponent {
formControl: FormControl = new FormControl(true, []); formControl: FormControl = new FormControl(true, []);
downloadInProgress: {[key: string]: boolean} = {};
private readonly statService = inject(StatisticsService); private readonly statService = inject(StatisticsService);
private readonly translocoService = inject(TranslocoService); private readonly translocoService = inject(TranslocoService);
private readonly ngbModal = inject(NgbModal);
constructor() { constructor() {
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay()); 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 => { 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}; 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>) { 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();
});
}
} }

View File

@ -1513,7 +1513,8 @@
"users-online-count": "{{num}} Users online", "users-online-count": "{{num}} Users online",
"active-events-title": "Active Events:", "active-events-title": "Active Events:",
"no-data": "Not much going on here", "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": { "shortcuts-modal": {
@ -1868,7 +1869,9 @@
"total-size-header": "Total Size", "total-size-header": "Total Size",
"total-files-header": "Total Files", "total-files-header": "Total Files",
"not-classified": "Not Classified", "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": { "reading-activity": {
@ -2326,7 +2329,10 @@
"issue-hash-num": "Issue #", "issue-hash-num": "Issue #",
"issue-num": "Issue", "issue-num": "Issue",
"chapter-num": "Chapter", "chapter-num": "Chapter",
"volume-num": "Volume" "volume-num": "Volume",
"chapter-num-shorthand": "Ch {{num}}",
"issue-num-shorthand": "#{{num}}",
"volume-num-shorthand": "Vol {{num}}"
} }
} }

View File

@ -242,6 +242,10 @@
--event-widget-item-border-color: rgba(53, 53, 53, 0.5); --event-widget-item-border-color: rgba(53, 53, 53, 0.5);
--event-widget-border-color: rgba(1, 4, 9, 0.5); --event-widget-border-color: rgba(1, 4, 9, 0.5);
--event-widget-info-bg-color: #b6d4fe; --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 */
--search-result-text-lite-color: initial; --search-result-text-lite-color: initial;