Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-08-24 19:23:57 -05:00 committed by GitHub
parent dbc4f35107
commit c93af3e56f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
126 changed files with 1989 additions and 2877 deletions

View File

@ -12,10 +12,10 @@
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
</PropertyGroup> </PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' "> <!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<Delete Files="../openapi.json" /> <!-- <Delete Files="../openapi.json" />-->
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" /> <!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
</Target> <!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>

View File

@ -88,6 +88,7 @@ public class ChapterController : BaseApiController
chapter.AgeRating = dto.AgeRating; chapter.AgeRating = dto.AgeRating;
} }
dto.Summary ??= string.Empty;
if (chapter.Summary != dto.Summary.Trim()) if (chapter.Summary != dto.Summary.Trim())
{ {
@ -260,6 +261,8 @@ public class ChapterController : BaseApiController
#endregion #endregion
_unitOfWork.ChapterRepository.Update(chapter);
if (!_unitOfWork.HasChanges()) if (!_unitOfWork.HasChanges())
{ {
return Ok(); return Ok();

View File

@ -310,9 +310,9 @@ public class LibraryController : BaseApiController
[Authorize(Policy = "RequireAdminRole")] [Authorize(Policy = "RequireAdminRole")]
[HttpPost("refresh-metadata")] [HttpPost("refresh-metadata")]
public ActionResult RefreshMetadata(int libraryId, bool force = true) public ActionResult RefreshMetadata(int libraryId, bool force = true, bool forceColorscape = true)
{ {
_taskScheduler.RefreshMetadata(libraryId, force); _taskScheduler.RefreshMetadata(libraryId, force, forceColorscape);
return Ok(); return Ok();
} }

View File

@ -835,16 +835,26 @@ public class ReaderController : BaseApiController
return Ok(_unitOfWork.UserTableOfContentRepository.GetPersonalToC(User.GetUserId(), chapterId)); return Ok(_unitOfWork.UserTableOfContentRepository.GetPersonalToC(User.GetUserId(), chapterId));
} }
/// <summary>
/// Deletes the user's personal table of content for the given chapter
/// </summary>
/// <param name="chapterId"></param>
/// <param name="pageNum"></param>
/// <param name="title"></param>
/// <returns></returns>
[HttpDelete("ptoc")] [HttpDelete("ptoc")]
public async Task<ActionResult> DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title) public async Task<ActionResult> DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(title)) return BadRequest(await _localizationService.Translate(userId, "name-required")); if (string.IsNullOrWhiteSpace(title)) return BadRequest(await _localizationService.Translate(userId, "name-required"));
if (pageNum < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number")); if (pageNum < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number"));
var toc = await _unitOfWork.UserTableOfContentRepository.Get(userId, chapterId, pageNum, title); var toc = await _unitOfWork.UserTableOfContentRepository.Get(userId, chapterId, pageNum, title);
if (toc == null) return Ok(); if (toc == null) return Ok();
_unitOfWork.UserTableOfContentRepository.Remove(toc); _unitOfWork.UserTableOfContentRepository.Remove(toc);
await _unitOfWork.CommitAsync(); await _unitOfWork.CommitAsync();
return Ok(); return Ok();
} }

View File

@ -402,7 +402,7 @@ public class SeriesController : BaseApiController
[HttpPost("refresh-metadata")] [HttpPost("refresh-metadata")]
public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto)
{ {
_taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate); _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, refreshSeriesDto.ForceUpdate, refreshSeriesDto.ForceColorscape);
return Ok(); return Ok();
} }

View File

@ -18,4 +18,9 @@ public class RefreshSeriesDto
/// </summary> /// </summary>
/// <remarks>This is expensive if true. Defaults to true.</remarks> /// <remarks>This is expensive if true. Defaults to true.</remarks>
public bool ForceUpdate { get; init; } = true; public bool ForceUpdate { get; init; } = true;
/// <summary>
/// Should the task force re-calculation of colorscape.
/// </summary>
/// <remarks>This is expensive if true. Defaults to true.</remarks>
public bool ForceColorscape { get; init; } = false;
} }

View File

@ -82,7 +82,7 @@ public class ImageService : IImageService
public const string CollectionTagCoverImageRegex = @"tag\d+"; public const string CollectionTagCoverImageRegex = @"tag\d+";
public const string ReadingListCoverImageRegex = @"readinglist\d+"; public const string ReadingListCoverImageRegex = @"readinglist\d+";
private const double WhiteThreshold = 0.90; // Colors with lightness above this are considered too close to white private const double WhiteThreshold = 0.95; // Colors with lightness above this are considered too close to white
private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black
@ -486,9 +486,11 @@ public class ImageService : IImageService
// Resize the image to speed up processing // Resize the image to speed up processing
var resizedImage = image.Resize(0.1); var resizedImage = image.Resize(0.1);
var processedImage = PreProcessImage(resizedImage);
// Convert image to RGB array // Convert image to RGB array
var pixels = resizedImage.WriteToMemory().ToArray(); var pixels = processedImage.WriteToMemory().ToArray();
// Convert to list of Vector3 (RGB) // Convert to list of Vector3 (RGB)
var rgbPixels = new List<Vector3>(); var rgbPixels = new List<Vector3>();
@ -502,6 +504,9 @@ public class ImageService : IImageService
var sorted = SortByVibrancy(clusters); var sorted = SortByVibrancy(clusters);
// Ensure white and black are not selected as primary/secondary colors
sorted = sorted.Where(c => !IsCloseToWhiteOrBlack(c)).ToList();
if (sorted.Count >= 2) if (sorted.Count >= 2)
{ {
return (sorted[0], sorted[1]); return (sorted[0], sorted[1]);
@ -535,17 +540,18 @@ public class ImageService : IImageService
private static Image PreProcessImage(Image image) private static Image PreProcessImage(Image image)
{ {
return image;
// Create a mask for white and black pixels // Create a mask for white and black pixels
var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100); var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100);
var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100); var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100);
// Create a replacement color (e.g., medium gray) // Create a replacement color (e.g., medium gray)
var replacementColor = new[] { 128.0, 128.0, 128.0 }; var replacementColor = new[] { 240.0, 240.0, 240.0 };
// Apply the masks to replace white and black pixels // Apply the masks to replace white and black pixels
var processedImage = image.Copy(); var processedImage = image.Copy();
processedImage = processedImage.Ifthenelse(whiteMask, replacementColor); processedImage = processedImage.Ifthenelse(whiteMask, replacementColor);
processedImage = processedImage.Ifthenelse(blackMask, replacementColor); //processedImage = processedImage.Ifthenelse(blackMask, replacementColor);
return processedImage; return processedImage;
} }
@ -627,6 +633,13 @@ public class ImageService : IImageService
}).ToList(); }).ToList();
} }
private static bool IsCloseToWhiteOrBlack(Vector3 color)
{
var threshold = 30;
return (color.X > 255 - threshold && color.Y > 255 - threshold && color.Z > 255 - threshold) ||
(color.X < threshold && color.Y < threshold && color.Z < threshold);
}
private static string RgbToHex(Vector3 color) private static string RgbToHex(Vector3 color)
{ {
return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}"; return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}";

View File

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Comparators; using API.Comparators;
@ -27,7 +26,7 @@ public interface IMetadataService
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false); Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false);
/// <summary> /// <summary>
/// Performs a forced refresh of cover images just for a series and it's nested entities /// Performs a forced refresh of cover images just for a series and it's nested entities
/// </summary> /// </summary>
@ -35,8 +34,8 @@ public interface IMetadataService
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param> /// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true); Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true, bool forceColorScape = true);
Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false); Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false, bool forceColorScape = true);
Task RemoveAbandonedMetadataKeys(); Task RemoveAbandonedMetadataKeys();
} }
@ -75,7 +74,8 @@ public class MetadataService : IMetadataService
/// <param name="chapter"></param> /// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param> /// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
/// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param> /// <param name="encodeFormat">Convert image to Encoding Format when extracting the cover</param>
private Task<bool> UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) /// <param name="forceColorScape">Force colorscape gen</param>
private Task<bool> UpdateChapterCoverImage(Chapter? chapter, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false)
{ {
if (chapter == null) return Task.FromResult(false); if (chapter == null) return Task.FromResult(false);
@ -86,7 +86,7 @@ public class MetadataService : IMetadataService
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage), _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
{ {
if (NeedsColorSpace(chapter)) if (NeedsColorSpace(chapter, forceColorScape))
{ {
_imageService.UpdateColorScape(chapter); _imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter); _unitOfWork.ChapterRepository.Update(chapter);
@ -118,9 +118,11 @@ public class MetadataService : IMetadataService
firstFile.UpdateLastModified(); firstFile.UpdateLastModified();
} }
private static bool NeedsColorSpace(IHasCoverImage? entity) private static bool NeedsColorSpace(IHasCoverImage? entity, bool force)
{ {
if (entity == null) return false; if (entity == null) return false;
if (force) return true;
return !string.IsNullOrEmpty(entity.CoverImage) && return !string.IsNullOrEmpty(entity.CoverImage) &&
(string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor)); (string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor));
} }
@ -132,7 +134,8 @@ public class MetadataService : IMetadataService
/// </summary> /// </summary>
/// <param name="volume"></param> /// <param name="volume"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param> /// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
private Task<bool> UpdateVolumeCoverImage(Volume? volume, bool forceUpdate) /// <param name="forceColorScape">Force updating colorscape</param>
private Task<bool> UpdateVolumeCoverImage(Volume? volume, bool forceUpdate, bool forceColorScape = false)
{ {
// We need to check if Volume coverImage matches first chapters if forceUpdate is false // We need to check if Volume coverImage matches first chapters if forceUpdate is false
if (volume == null) return Task.FromResult(false); if (volume == null) return Task.FromResult(false);
@ -141,7 +144,7 @@ public class MetadataService : IMetadataService
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage), _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage),
null, volume.Created, forceUpdate)) null, volume.Created, forceUpdate))
{ {
if (NeedsColorSpace(volume)) if (NeedsColorSpace(volume, forceColorScape))
{ {
_imageService.UpdateColorScape(volume); _imageService.UpdateColorScape(volume);
_unitOfWork.VolumeRepository.Update(volume); _unitOfWork.VolumeRepository.Update(volume);
@ -176,7 +179,7 @@ public class MetadataService : IMetadataService
/// </summary> /// </summary>
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param> /// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate) private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false)
{ {
if (series == null) return Task.CompletedTask; if (series == null) return Task.CompletedTask;
@ -185,13 +188,12 @@ public class MetadataService : IMetadataService
null, series.Created, forceUpdate, series.CoverImageLocked)) null, series.Created, forceUpdate, series.CoverImageLocked))
{ {
// Check if we don't have a primary/seconary color // Check if we don't have a primary/seconary color
if (NeedsColorSpace(series)) if (NeedsColorSpace(series, forceColorScape))
{ {
_imageService.UpdateColorScape(series); _imageService.UpdateColorScape(series);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
} }
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -211,7 +213,7 @@ public class MetadataService : IMetadataService
/// <param name="series"></param> /// <param name="series"></param>
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
/// <param name="encodeFormat"></param> /// <param name="encodeFormat"></param>
private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize) private async Task ProcessSeriesCoverGen(Series series, bool forceUpdate, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceColorScape = false)
{ {
_logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName); _logger.LogDebug("[MetadataService] Processing cover image generation for series: {SeriesName}", series.OriginalName);
try try
@ -224,7 +226,7 @@ public class MetadataService : IMetadataService
var index = 0; var index = 0;
foreach (var chapter in volume.Chapters) foreach (var chapter in volume.Chapters)
{ {
var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize); var chapterUpdated = await UpdateChapterCoverImage(chapter, forceUpdate, encodeFormat, coverImageSize, forceColorScape);
// If cover was update, either the file has changed or first scan and we should force a metadata update // If cover was update, either the file has changed or first scan and we should force a metadata update
UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated); UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated);
if (index == 0 && chapterUpdated) if (index == 0 && chapterUpdated)
@ -235,7 +237,7 @@ public class MetadataService : IMetadataService
index++; index++;
} }
var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate); var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape);
if (volumeIndex == 0 && volumeUpdated) if (volumeIndex == 0 && volumeUpdated)
{ {
firstVolumeUpdated = true; firstVolumeUpdated = true;
@ -243,7 +245,7 @@ public class MetadataService : IMetadataService
volumeIndex++; volumeIndex++;
} }
await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate); await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -258,9 +260,10 @@ public class MetadataService : IMetadataService
/// <remarks>This can be heavy on memory first run</remarks> /// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param> /// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
/// <param name="forceColorScape">Force updating colorscape</param>
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false) public async Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false)
{ {
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
if (library == null) return; if (library == null) return;
@ -308,7 +311,7 @@ public class MetadataService : IMetadataService
try try
{ {
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize); await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -349,7 +352,8 @@ public class MetadataService : IMetadataService
/// <param name="libraryId"></param> /// <param name="libraryId"></param>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param> /// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
public async Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true) /// <param name="forceColorscape">Will ensure that the colorscape is regenned</param>
public async Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true, bool forceColorScape = true)
{ {
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
if (series == null) if (series == null)
@ -361,7 +365,8 @@ public class MetadataService : IMetadataService
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var encodeFormat = settings.EncodeMediaAs; var encodeFormat = settings.EncodeMediaAs;
var coverImageSize = settings.CoverImageSize; var coverImageSize = settings.CoverImageSize;
await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate);
await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate, forceColorScape);
} }
/// <summary> /// <summary>
@ -370,13 +375,14 @@ public class MetadataService : IMetadataService
/// <param name="series">A full Series, with metadata, chapters, etc</param> /// <param name="series">A full Series, with metadata, chapters, etc</param>
/// <param name="encodeFormat">When saving the file, what encoding should be used</param> /// <param name="encodeFormat">When saving the file, what encoding should be used</param>
/// <param name="forceUpdate"></param> /// <param name="forceUpdate"></param>
public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false) /// <param name="forceColorScape">Forces just colorscape generation</param>
public async Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false, bool forceColorScape = true)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name)); MessageFactory.CoverUpdateProgressEvent(series.LibraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize); await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape);
if (_unitOfWork.HasChanges()) if (_unitOfWork.HasChanges())

View File

@ -10,6 +10,7 @@ namespace API.Services;
public static class ReviewService public static class ReviewService
{ {
private const int BodyTextLimit = 175;
public static IEnumerable<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews) public static IEnumerable<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{ {
IList<UserReviewDto> externalReviews; IList<UserReviewDto> externalReviews;
@ -76,7 +77,7 @@ public static class ReviewService
plainText = Regex.Replace(plainText, @"__", string.Empty); plainText = Regex.Replace(plainText, @"__", string.Empty);
// Take the first 100 characters // Take the first 100 characters
plainText = plainText.Length > 100 ? plainText.Substring(0, 100) : plainText; plainText = plainText.Length > 100 ? plainText.Substring(0, BodyTextLimit) : plainText;
return plainText + "…"; return plainText + "…";
} }

View File

@ -27,8 +27,8 @@ public interface ITaskScheduler
Task ScanLibrary(int libraryId, bool force = false); Task ScanLibrary(int libraryId, bool force = false);
Task 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, bool forceColorscape = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false);
Task 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);
@ -371,12 +371,12 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
} }
public void RefreshMetadata(int libraryId, bool forceUpdate = true) public void RefreshMetadata(int libraryId, bool forceUpdate = true, bool forceColorscape = true)
{ {
var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary", var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary",
[libraryId, true]) || [libraryId, true, true]) ||
HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary", HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary",
[libraryId, false]); [libraryId, false, false]);
if (alreadyEnqueued) if (alreadyEnqueued)
{ {
_logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping");
@ -384,19 +384,19 @@ public class TaskScheduler : ITaskScheduler
} }
_logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId); _logger.LogInformation("Enqueuing library metadata refresh for: {LibraryId}", libraryId);
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForLibrary(libraryId, forceUpdate)); BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForLibrary(libraryId, forceUpdate, forceColorscape));
} }
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false) public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false)
{ {
if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", [libraryId, seriesId, forceUpdate])) if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", [libraryId, seriesId, forceUpdate, forceColorscape]))
{ {
_logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping");
return; return;
} }
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId); _logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate)); BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate, forceColorscape));
} }
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)

View File

@ -221,7 +221,7 @@ public class ScannerService : IScannerService
var libraryPaths = library.Folders.Select(f => f.Path).ToList(); var libraryPaths = library.Folders.Select(f => f.Path).ToList();
if (await ShouldScanSeries(seriesId, library, libraryPaths, series, true) != ScanCancelReason.NoCancel) if (await ShouldScanSeries(seriesId, library, libraryPaths, series, true) != ScanCancelReason.NoCancel)
{ {
BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(series.LibraryId, seriesId, false)); BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(series.LibraryId, seriesId, false, false));
BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(library.Id, seriesId, bypassFolderOptimizationChecks)); BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(library.Id, seriesId, bypassFolderOptimizationChecks));
return; return;
} }

View File

@ -16,9 +16,8 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.btn { .main-container {
//padding: 4px 8px !important; overflow: unset !important;
//font-size: 0.8rem !important;
} }
.btn-group > .btn.dropdown-toggle-split:not(first-child){ .btn-group > .btn.dropdown-toggle-split:not(first-child){
@ -112,6 +111,7 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar; -ms-overflow-style: -ms-autohiding-scrollbar;
scrollbar-width: none; scrollbar-width: none;
box-shadow: inset -1px -2px 0px -1px var(--elevation-layer9);
} }
.carousel-tabs-container::-webkit-scrollbar { .carousel-tabs-container::-webkit-scrollbar {
display: none; display: none;
@ -119,3 +119,92 @@
.nav-tabs { .nav-tabs {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.upper-details {
font-size: 0.9rem;
}
::ng-deep .carousel-container .header i.fa-plus, ::ng-deep .carousel-container .header i.fa-pen{
border-width: 1px;
border-style: solid;
border-radius: 5px;
border-color: var(--primary-color);
padding: 5px;
vertical-align: middle;
&:hover {
background-color: var(--primary-color-dark-shade);
}
}
::ng-deep .image-container.mobile-bg app-image img {
max-height: 400px;
object-fit: contain;
}
@media (max-width: 768px) {
.carousel-tabs-container {
mask-image: linear-gradient(transparent, black 0%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%);
}
}
::ng-deep .image-container.mobile-bg app-image img {
max-height: 100dvh !important;
object-fit: cover !important;
}
/* col-lg */
@media screen and (max-width: 991px) {
.image-container.mobile-bg{
width: 100vw;
top: calc(var(--nav-offset) - 20px);
left: 0;
pointer-events: none;
position: fixed !important;
display: block !important;
max-height: unset !important;
max-width: unset !important;
height: 100dvh !important;
}
::ng-deep .image-container.mobile-bg app-image img {
max-height: unset !important;
opacity: 0.05 !important;
filter: blur(5px) !important;
max-width: 100dvw;
height: 100dvh !important;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
object-fit: cover;
}
.progress-banner {
display:none;
}
.under-image {
display: none;
}
}
.upper-details {
font-size: 0.9rem;
}
@media (max-width: 768px) {
.carousel-tabs-container {
mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%);
}
}
.under-image {
background-color: var(--breadcrumb-bg-color);
color: white;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
text-align: center;
}

View File

@ -91,9 +91,10 @@ export class ActionService {
* @param library Partial Library, must have id and name populated * @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes * @param callback Optional callback to perform actions after API completes
* @param forceUpdate Optional Should we force * @param forceUpdate Optional Should we force
* @param forceColorscape Optional Should we force colorscape gen
* @returns * @returns
*/ */
async refreshLibraryMetadata(library: Partial<Library>, callback?: LibraryActionCallback, forceUpdate: boolean = true) { async refreshLibraryMetadata(library: Partial<Library>, callback?: LibraryActionCallback, forceUpdate: boolean = true, forceColorscape: boolean = false) {
if (!library.hasOwnProperty('id') || library.id === undefined) { if (!library.hasOwnProperty('id') || library.id === undefined) {
return; return;
} }
@ -110,7 +111,7 @@ export class ActionService {
const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued'; const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued';
this.libraryService.refreshMetadata(library?.id, forceUpdate).subscribe((res: any) => { this.libraryService.refreshMetadata(library?.id, forceUpdate, forceColorscape).subscribe((res: any) => {
this.toastr.info(translate(message, {name: library.name})); this.toastr.info(translate(message, {name: library.name}));
if (callback) { if (callback) {
@ -236,8 +237,9 @@ export class ActionService {
* @param series Series, must have libraryId, id and name populated * @param series Series, must have libraryId, id and name populated
* @param callback Optional callback to perform actions after API completes * @param callback Optional callback to perform actions after API completes
* @param forceUpdate If cache should be checked or not * @param forceUpdate If cache should be checked or not
* @param forceColorscape If cache should be checked or not
*/ */
async refreshSeriesMetadata(series: Series, callback?: SeriesActionCallback, forceUpdate: boolean = true) { async refreshSeriesMetadata(series: Series, callback?: SeriesActionCallback, forceUpdate: boolean = true, forceColorscape: boolean = false) {
// Prompt the user if we are doing a forced call // Prompt the user if we are doing a forced call
if (forceUpdate) { if (forceUpdate) {
@ -251,7 +253,7 @@ export class ActionService {
const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued'; const message = forceUpdate ? 'toasts.refresh-covers-queued' : 'toasts.generate-colorscape-queued';
this.seriesService.refreshMetadata(series, forceUpdate).pipe(take(1)).subscribe((res: any) => { this.seriesService.refreshMetadata(series, forceUpdate, forceColorscape).pipe(take(1)).subscribe((res: any) => {
this.toastr.info(translate(message, {name: series.name})); this.toastr.info(translate(message, {name: series.name}));
if (callback) { if (callback) {
callback(series); callback(series);

View File

@ -97,8 +97,8 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {}); return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {});
} }
refreshMetadata(libraryId: number, forceUpdate = false) { refreshMetadata(libraryId: number, forceUpdate = false, forceColorscape = false) {
return this.httpClient.post(this.baseUrl + 'library/refresh-metadata?libraryId=' + libraryId + '&force=' + forceUpdate, {}); return this.httpClient.post(this.baseUrl + `library/refresh-metadata?libraryId=${libraryId}&force=${forceUpdate}&forceColorscape=${forceColorscape}`, {});
} }
create(model: {name: string, type: number, folders: string[]}) { create(model: {name: string, type: number, folders: string[]}) {

View File

@ -1,13 +1,12 @@
import { DOCUMENT } from '@angular/common'; import {DOCUMENT} from '@angular/common';
import {DestroyRef, inject, Inject, Injectable, OnDestroy, Renderer2, RendererFactory2} from '@angular/core'; import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
import {filter, ReplaySubject, Subject, take} from 'rxjs'; import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs';
import {HttpClient} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment"; import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
import {AccountService} from "./account.service"; import {AccountService} from "./account.service";
import {map, tap} from "rxjs/operators"; import {map} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router"; import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -98,22 +97,28 @@ export class NavService {
* Shows the top nav bar. This should be visible on all pages except the reader. * Shows the top nav bar. This should be visible on all pages except the reader.
*/ */
showNavBar() { showNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', 'var(--nav-offset)'); setTimeout(() => {
this.renderer.removeStyle(this.document.querySelector('body'), 'scrollbar-gutter'); const bodyElem = this.document.querySelector('body');
this.renderer.setStyle(this.document.querySelector('body'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))'); this.renderer.setStyle(bodyElem, 'margin-top', 'var(--nav-offset)');
this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))'); this.renderer.removeStyle(bodyElem, 'scrollbar-gutter');
this.navbarVisibleSource.next(true); this.renderer.setStyle(bodyElem, 'height', 'calc(var(--vh)*100 - var(--nav-offset))');
this.renderer.setStyle(this.document.querySelector('html'), 'height', 'calc(var(--vh)*100 - var(--nav-offset))');
this.navbarVisibleSource.next(true);
}, 10);
} }
/** /**
* Hides the top nav bar. * Hides the top nav bar.
*/ */
hideNavBar() { hideNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px'); setTimeout(() => {
this.renderer.setStyle(this.document.querySelector('body'), 'scrollbar-gutter', 'initial'); const bodyElem = this.document.querySelector('body');
this.renderer.removeStyle(this.document.querySelector('body'), 'height'); this.renderer.removeStyle(bodyElem, 'height');
this.renderer.removeStyle(this.document.querySelector('html'), 'height'); this.renderer.removeStyle(this.document.querySelector('html'), 'height');
this.navbarVisibleSource.next(false); this.renderer.setStyle(bodyElem, 'margin-top', '0px', RendererStyleFlags2.Important);
this.renderer.setStyle(bodyElem, 'scrollbar-gutter', 'initial', RendererStyleFlags2.Important);
this.navbarVisibleSource.next(false);
}, 10);
} }
/** /**
@ -139,8 +144,8 @@ export class NavService {
}); });
} }
collapseSideNav(state: boolean) { collapseSideNav(isCollapsed: boolean) {
this.sideNavCollapseSource.next(state); this.sideNavCollapseSource.next(isCollapsed);
localStorage.setItem(this.localStorageSideNavKey, state + ''); localStorage.setItem(this.localStorageSideNavKey, isCollapsed + '');
} }
} }

View File

@ -375,7 +375,7 @@ export class ReaderService {
} }
// Sort the chapters, then grab first if no reading progress // Sort the chapters, then grab first if no reading progress
this.readChapter(libraryId, seriesId, [...volume.chapters].sort(this.utilityService.sortChapters)[0]); this.readChapter(libraryId, seriesId, [...volume.chapters].sort(this.utilityService.sortChapters)[0], incognitoMode);
} }
readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) { readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) {

View File

@ -143,8 +143,8 @@ export class SeriesService {
} }
refreshMetadata(series: Series, force = true) { refreshMetadata(series: Series, force = true, forceColorscape = true) {
return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id, forceUpdate: force}); return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id, forceUpdate: force, forceColorscape});
} }
scan(libraryId: number, seriesId: number, force = false) { scan(libraryId: number, seriesId: number, force = false) {

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'details-tab'"> <ng-container *transloco="let t; read: 'details-tab'">
<div class="details pb-3">
<div class="mb-3"> <div class="mb-3">
<app-carousel-reel [items]="genres" [title]="t('genres-title')"> <app-carousel-reel [items]="genres" [title]="t('genres-title')">
<ng-template #carouselItem let-item> <ng-template #carouselItem let-item>
@ -132,4 +132,5 @@
</ng-template> </ng-template>
</app-carousel-reel> </app-carousel-reel>
</div> </div>
</div>
</ng-container> </ng-container>

View File

@ -44,7 +44,6 @@ import {SettingButtonComponent} from "../../settings/_components/setting-button/
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component"; import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {MangaFormat} from "../../_models/manga-format"; import {MangaFormat} from "../../_models/manga-format";
@ -56,6 +55,7 @@ import {ImageComponent} from "../../shared/image/image.component";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {ReadTimePipe} from "../../_pipes/read-time.pipe"; import {ReadTimePipe} from "../../_pipes/read-time.pipe";
import {ChapterService} from "../../_services/chapter.service"; import {ChapterService} from "../../_services/chapter.service";
import {AgeRating} from "../../_models/metadata/age-rating";
enum TabID { enum TabID {
General = 'general-tab', General = 'general-tab',
@ -100,7 +100,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
CoverImageChooserComponent, CoverImageChooserComponent,
EditChapterProgressComponent, EditChapterProgressComponent,
NgbInputDatepicker, NgbInputDatepicker,
EntityInfoCardsComponent,
CompactNumberPipe, CompactNumberPipe,
IconAndTitleComponent, IconAndTitleComponent,
DefaultDatePipe, DefaultDatePipe,
@ -120,7 +119,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
export class EditChapterModalComponent implements OnInit { export class EditChapterModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal); protected readonly modal = inject(NgbActiveModal);
private readonly seriesService = inject(SeriesService);
public readonly utilityService = inject(UtilityService); public readonly utilityService = inject(UtilityService);
public readonly imageService = inject(ImageService); public readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService); private readonly uploadService = inject(UploadService);
@ -183,7 +181,7 @@ export class EditChapterModalComponent implements OnInit {
this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, [])); this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, []));
this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)])); this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)]));
this.editForm.addControl('summary', new FormControl(this.chapter.summary, [])); this.editForm.addControl('summary', new FormControl(this.chapter.summary || '', []));
this.editForm.addControl('language', new FormControl(this.chapter.language, [])); this.editForm.addControl('language', new FormControl(this.chapter.language, []));
this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, [])); this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, []));
this.editForm.addControl('ageRating', new FormControl(this.chapter.ageRating, [])); this.editForm.addControl('ageRating', new FormControl(this.chapter.ageRating, []));
@ -251,6 +249,14 @@ export class EditChapterModalComponent implements OnInit {
const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0; const selectedIndex = this.editForm.get('coverImageIndex')?.value || 0;
this.chapter.releaseDate = model.releaseDate; this.chapter.releaseDate = model.releaseDate;
this.chapter.ageRating = model.ageRating as AgeRating;
this.chapter.genres = model.genres;
this.chapter.tags = model.tags;
this.chapter.sortOrder = model.sortOrder;
this.chapter.language = model.language;
this.chapter.titleName = model.titleName;
this.chapter.summary = model.summary;
this.chapter.isbn = model.isbn;
const apis = [ const apis = [

View File

@ -17,7 +17,6 @@ import {EntityTitleComponent} from "../../cards/entity-title/entity-title.compon
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {EntityInfoCardsComponent} from "../../cards/entity-info-cards/entity-info-cards.component";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component"; import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
@ -83,7 +82,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
CoverImageChooserComponent, CoverImageChooserComponent,
EditChapterProgressComponent, EditChapterProgressComponent,
NgbInputDatepicker, NgbInputDatepicker,
EntityInfoCardsComponent,
CompactNumberPipe, CompactNumberPipe,
IconAndTitleComponent, IconAndTitleComponent,
DefaultDatePipe, DefaultDatePipe,

View File

@ -1,15 +1,15 @@
<ng-container *transloco="let t; read:'review-card'"> <ng-container *transloco="let t; read:'review-card'">
<div class="card review-card clickable mb-3" (click)="showModal()"> <div class="card review-card clickable mb-3" (click)="showModal()">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-2 d-none d-md-block p-2"> <div class="col-md-2 col-sm-2 col-2 d-block p-2">
@if (isMyReview) { @if (isMyReview) {
<i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i> <i class="d-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
<img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="40" height="40" alt=""> <img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage:true" width="40" height="40" alt="">
} @else { } @else {
<img class="me-2" [ngSrc]="review.provider | providerImage" width="40" height="40" alt=""> <img class="me-2" [ngSrc]="review.provider | providerImage:true" width="40" height="40" alt="">
} }
</div> </div>
<div class="col-md-10"> <div class="col-md-10 col-sm-10 col-10">
<div class="card-body p-2"> <div class="card-body p-2">
<!-- <!--
<h6 class="card-title"> <h6 class="card-title">

View File

@ -2,22 +2,25 @@
<div class="offcanvas-header"> <div class="offcanvas-header">
<h5 class="offcanvas-title"> <h5 class="offcanvas-title">
{{name}} {{name}}
</h5> </h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('common.close')" (click)="close()"></button> <button type="button" class="btn-close text-reset" [attr.aria-label]="t('common.close')" (click)="close()"></button>
</div> </div>
<div class="offcanvas-body"> <div class="offcanvas-body">
<ng-container *ngIf="CoverUrl as coverUrl"> @if (CoverUrl; as coverUrl) {
<div style="width: 160px" class="mx-auto mb-3"> <div style="width: 160px" class="mx-auto mb-3">
<app-image *ngIf="coverUrl" height="232.91px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '232.91px'}" [imageUrl]="coverUrl"></app-image> @if (coverUrl) {
<app-image height="232.91px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '232.91px'}" [imageUrl]="coverUrl"></app-image>
}
</div> </div>
</ng-container> }
<ng-container *ngIf="externalSeries; else localSeriesBody"> @if (externalSeries) {
<div *ngIf="(externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0" class="text-muted muted mb-2"> @if ((externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0) {
{{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}} <div class="text-muted muted mb-2">
</div> {{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}}
</div>
}
@if(isExternalSeries && externalSeries) { @if(isExternalSeries && externalSeries) {
<div class="text-muted muted mb-2"> <div class="text-muted muted mb-2">
@ -26,14 +29,20 @@
</div> </div>
} }
<app-read-more *ngIf="externalSeries.summary" [maxLength]="300" [text]="externalSeries.summary"></app-read-more> @if (externalSeries.summary) {
<app-read-more [maxLength]="300" [text]="externalSeries.summary"></app-read-more>
}
}
<a class="btn btn-primary col-12 mt-2" [href]="url" target="_blank" rel="noopener noreferrer">
{{t('series-preview-drawer.view-series')}}
</a>
@if (externalSeries) {
<div class="mt-3"> <div class="mt-3">
<app-metadata-detail [tags]="externalSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')"> <app-metadata-detail [tags]="externalSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-tag-badge> <span class="dark-exempt btn-icon not-clickable">{{item}}</span>
{{item}}
</app-tag-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
</div> </div>
@ -41,25 +50,22 @@
<div class="mt-3"> <div class="mt-3">
<app-metadata-detail [tags]="externalSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')"> <app-metadata-detail [tags]="externalSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-tag-badge> <span class="dark-exempt btn-icon not-clickable">{{item.name}}</span>
{{item.name}}
</app-tag-badge>
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<app-metadata-detail [tags]="externalSeries.staff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')"> <app-metadata-detail [tags]="externalSeries.staff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')" [includeComma]="false">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<div class="card mb-3"> <div class="card mb-3">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-3"> <div class="col-md-3">
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson"> @if (item.imageUrl && !item.imageUrl.endsWith('default.jpg')) {
<app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}" [imageUrl]="item.imageUrl" classes="person-img"></app-image> <app-image height="24px" width="24px" [styles]="{'object-fit': 'contain'}" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
</ng-container> } @else {
<ng-template #localPerson>
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i> <i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
</ng-template> }
</div> </div>
<div class="col-md-9"> <div class="col-md-9">
<div class="card-body"> <div class="card-body">
@ -72,67 +78,56 @@
</ng-template> </ng-template>
</app-metadata-detail> </app-metadata-detail>
</div> </div>
</ng-container> }
@else if(localSeries) {
<div class="d-inline-block mb-2 mt-2" style="width: 100%">
<span class="text-muted muted">{{localSeries.publicationStatus | publicationStatus}}</span>
<button class="btn btn-secondary btn-sm float-end me-3"
(click)="toggleWantToRead()"
ngbTooltip="{{wantToRead ? t('series-preview-drawer.remove-from-want-to-read') : t('series-preview-drawer.add-to-want-to-read')}}">
<i class="{{wantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i>
</button>
</div>
<ng-template #localSeriesBody> <app-read-more [maxLength]="300" [text]="localSeries.summary"></app-read-more>
<ng-container *ngIf="localSeries">
<div class="d-inline-block mb-2" style="width: 100%">
<span class="text-muted muted">{{localSeries.publicationStatus | publicationStatus}}</span>
<button class="btn btn-secondary btn-sm float-end me-3"
(click)="toggleWantToRead()"
ngbTooltip="{{wantToRead ? t('series-preview-drawer.remove-from-want-to-read') : t('series-preview-drawer.add-to-want-to-read')}}">
<i class="{{wantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i>
</button>
</div>
<app-read-more [maxLength]="300" [text]="localSeries.summary"></app-read-more>
<div class="mt-3"> <div class="mt-3">
<app-metadata-detail [tags]="localSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')"> <app-metadata-detail [tags]="localSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-tag-badge> <a class="dark-exempt btn-icon not-clickable">{{item.title}}</a>
{{item.title}} </ng-template>
</app-tag-badge> </app-metadata-detail>
</ng-template> </div>
</app-metadata-detail>
</div>
<div class="mt-3"> <div class="mt-3">
<app-metadata-detail [tags]="localSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')"> <app-metadata-detail [tags]="localSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<app-tag-badge> <span class="dark-exempt btn-icon not-clickable">{{item.title}}</span>
{{item.title}} </ng-template>
</app-tag-badge> </app-metadata-detail>
</ng-template> </div>
</app-metadata-detail>
</div>
<div class="mt-3"> <div class="mt-3">
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')"> <app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')" [includeComma]="false">
<ng-template #itemTemplate let-item> <ng-template #itemTemplate let-item>
<div class="card mb-3"> <div class="card mb-3">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-4"> <div class="col-md-4">
<i class="fa fa-user-circle align-self-center" style="font-size: 28px; margin-top: 24px; margin-left: 24px" aria-hidden="true"></i> <i class="fa fa-user-circle align-self-center" style="font-size: 28px; margin-top: 24px; margin-left: 24px" aria-hidden="true"></i>
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="card-body"> <div class="card-body">
<h6 class="card-title">{{item.name}}</h6> <h6 class="card-title">{{item.name}}</h6>
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p> <p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
</div>
</div> </div>
</div> </div>
</div> </div>
</ng-template> </div>
</app-metadata-detail> </ng-template>
</div> </app-metadata-detail>
</div>
</ng-container> }
</ng-template>
<app-loading [loading]="isLoading"></app-loading> <app-loading [loading]="isLoading"></app-loading>
<a class="btn btn-primary col-12 mt-2" [href]="url" target="_blank" rel="noopener noreferrer">
{{t('series-preview-drawer.view-series')}}
</a>
</div> </div>
</ng-container> </ng-container>

View File

@ -15,4 +15,13 @@
a.read-more-link { a.read-more-link {
white-space: nowrap; white-space: nowrap;
} }
.not-clickable {
cursor: text;
}
.offcanvas-body {
mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
}

View File

@ -1,5 +1,5 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common'; import {NgOptimizedImage} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {NgbActiveOffcanvas, NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbActiveOffcanvas, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ExternalSeriesDetail, SeriesStaff} from "../../_models/series-detail/external-series-detail"; import {ExternalSeriesDetail, SeriesStaff} from "../../_models/series-detail/external-series-detail";
@ -17,17 +17,26 @@ import {SeriesMetadata} from "../../_models/metadata/series-metadata";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {ActionService} from "../../_services/action.service"; import {ActionService} from "../../_services/action.service";
import {ProviderImagePipe} from "../../_pipes/provider-image.pipe"; import {ProviderImagePipe} from "../../_pipes/provider-image.pipe";
import {ScrobbleProvider} from "../../_services/scrobbling.service"; import {FilterField} from "../../_models/metadata/v2/filter-field";
@Component({ @Component({
selector: 'app-series-preview-drawer', selector: 'app-series-preview-drawer',
standalone: true, standalone: true,
imports: [CommonModule, TranslocoDirective, ImageComponent, LoadingComponent, SafeHtmlPipe, A11yClickDirective, MetadataDetailComponent, PersonBadgeComponent, TagBadgeComponent, PublicationStatusPipe, ReadMoreComponent, NgbTooltip, NgOptimizedImage, ProviderImagePipe], imports: [TranslocoDirective, ImageComponent, LoadingComponent, SafeHtmlPipe, A11yClickDirective, MetadataDetailComponent, PersonBadgeComponent, TagBadgeComponent, PublicationStatusPipe, ReadMoreComponent, NgbTooltip, NgOptimizedImage, ProviderImagePipe],
templateUrl: './series-preview-drawer.component.html', templateUrl: './series-preview-drawer.component.html',
styleUrls: ['./series-preview-drawer.component.scss'], styleUrls: ['./series-preview-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class SeriesPreviewDrawerComponent implements OnInit { export class SeriesPreviewDrawerComponent implements OnInit {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly seriesService = inject(SeriesService);
private readonly imageService = inject(ImageService);
private readonly actionService = inject(ActionService);
private readonly cdRef = inject(ChangeDetectorRef);
protected readonly FilterField = FilterField;
@Input({required: true}) name!: string; @Input({required: true}) name!: string;
@Input() aniListId?: number; @Input() aniListId?: number;
@Input() malId?: number; @Input() malId?: number;
@ -42,11 +51,7 @@ export class SeriesPreviewDrawerComponent implements OnInit {
url: string = ''; url: string = '';
wantToRead: boolean = false; wantToRead: boolean = false;
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly seriesService = inject(SeriesService);
private readonly imageService = inject(ImageService);
private readonly actionService = inject(ActionService);
private readonly cdRef = inject(ChangeDetectorRef);
get CoverUrl() { get CoverUrl() {
if (this.isExternalSeries) { if (this.isExternalSeries) {

View File

@ -1,4 +1,24 @@
<ng-container *transloco="let t; read: 'related-tab'"> <ng-container *transloco="let t; read: 'related-tab'">
@if (relations.length > 0) {
<app-carousel-reel [items]="relations" [title]="t('relations-title')">
<ng-template #carouselItem let-item>
<app-series-card class="col-auto mt-2 mb-2" [series]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
</ng-template>
</app-carousel-reel>
}
@if (collections.length > 0) {
<app-carousel-reel [items]="collections" [title]="t('collections-title')">
<ng-template #carouselItem let-item>
<app-card-item [title]="item.title" [entity]="item"
[suppressLibraryLink]="true" [imageUrl]="imageService.getCollectionCoverImage(item.id)"
(clicked)="openCollection(item)"></app-card-item>
</ng-template>
</app-carousel-reel>
}
@if (readingLists.length > 0) { @if (readingLists.length > 0) {
<app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')"> <app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')">
<ng-template #carouselItem let-item> <ng-template #carouselItem let-item>

View File

@ -4,6 +4,16 @@ import {CardItemComponent} from "../../cards/card-item/card-item.component";
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {ImageService} from "../../_services/image.service"; import {ImageService} from "../../_services/image.service";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {UserCollection} from "../../_models/collection-tag";
import {Router} from "@angular/router";
import {SeriesCardComponent} from "../../cards/series-card/series-card.component";
import {Series} from "../../_models/series";
import {RelationKind} from "../../_models/series-detail/relation-kind";
export interface RelatedSeriesPair {
series: Series;
relation: RelationKind;
}
@Component({ @Component({
selector: 'app-related-tab', selector: 'app-related-tab',
@ -11,7 +21,8 @@ import {TranslocoDirective} from "@jsverse/transloco";
imports: [ imports: [
CardItemComponent, CardItemComponent,
CarouselReelComponent, CarouselReelComponent,
TranslocoDirective TranslocoDirective,
SeriesCardComponent
], ],
templateUrl: './related-tab.component.html', templateUrl: './related-tab.component.html',
styleUrl: './related-tab.component.scss', styleUrl: './related-tab.component.scss',
@ -20,11 +31,18 @@ import {TranslocoDirective} from "@jsverse/transloco";
export class RelatedTabComponent { export class RelatedTabComponent {
protected readonly imageService = inject(ImageService); protected readonly imageService = inject(ImageService);
protected readonly router = inject(Router);
@Input() readingLists: Array<ReadingList> = []; @Input() readingLists: Array<ReadingList> = [];
@Input() collections: Array<UserCollection> = [];
@Input() relations: Array<RelatedSeriesPair> = [];
openReadingList(readingList: ReadingList) { openReadingList(readingList: ReadingList) {
this.router.navigate(['lists', readingList.id]);
}
openCollection(collection: UserCollection) {
this.router.navigate(['collections', collection.id]);
} }
} }

View File

@ -16,8 +16,8 @@
<div class="input-group"> <div class="input-group">
<input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text" <input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched"> [class.is-invalid]="formControl.invalid && formControl.touched">
<button class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button> <button type="button" class="btn btn-outline-secondary" (click)="autofillGmail()">{{t('gmail-label')}}</button>
<button class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button> <button type="button" class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
</div> </div>
@if(settingsForm.dirty || settingsForm.touched) { @if(settingsForm.dirty || settingsForm.touched) {
@ -39,7 +39,7 @@
{{formControl.value | defaultValue}} {{formControl.value | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="senderAddress" id="settings-sender-address" /> <input type="text" class="form-control" formControlName="senderAddress" id="settings-sender-address" />
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }
@ -52,7 +52,7 @@
{{formControl.value | defaultValue}} {{formControl.value | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="senderDisplayName" id="settings-sender-displayname" /> <input type="text" class="form-control" formControlName="senderDisplayName" id="settings-sender-displayname" />
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }
@ -78,7 +78,7 @@
{{formControl.value | defaultValue}} {{formControl.value | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="number" inputmode="numeric" min="1" class="form-control" aria-describedby="email-header" formControlName="port" id="settings-port" /> <input type="number" inputmode="numeric" min="1" class="form-control" formControlName="port" id="settings-port" />
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }
@ -89,7 +89,7 @@
<app-setting-switch [title]="t('enable-ssl-label')"> <app-setting-switch [title]="t('enable-ssl-label')">
<ng-template #switch> <ng-template #switch>
<div class="form-check form-switch"> <div class="form-check form-switch">
<input id="setting-enable-ssl" type="checkbox" class="form-check-input" formControlName="enableOpds"> <input id="setting-enable-ssl" type="checkbox" class="form-check-input" formControlName="enableSsl">
</div> </div>
</ng-template> </ng-template>
</app-setting-switch> </app-setting-switch>
@ -103,7 +103,7 @@
{{formControl.value | defaultValue}} {{formControl.value | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="userName" id="settings-username" /> <input type="text" class="form-control" formControlName="userName" id="settings-username" />
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }
@ -116,7 +116,7 @@
{{formControl.value ? '********' : null | defaultValue}} {{formControl.value ? '********' : null | defaultValue}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="text" class="form-control" aria-describedby="email-header" formControlName="password" id="settings-password" /> <input type="text" class="form-control" formControlName="password" id="settings-password" />
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }
@ -129,7 +129,7 @@
{{formControl.value | bytes}} {{formControl.value | bytes}}
</ng-template> </ng-template>
<ng-template #edit> <ng-template #edit>
<input type="number" inputmode="numeric" min="1" class="form-control" aria-describedby="email-header" formControlName="sizeLimit" id="settings-size-limit" /> <input type="number" inputmode="numeric" min="1" class="form-control" formControlName="sizeLimit" id="settings-size-limit" />
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
} }

View File

@ -167,7 +167,7 @@ export class ManageLibraryComponent implements OnInit {
await this.actionService.refreshLibraryMetadata(library); await this.actionService.refreshLibraryMetadata(library);
break; break;
case(Action.GenerateColorScape): case(Action.GenerateColorScape):
await this.actionService.refreshLibraryMetadata(library, undefined, false); await this.actionService.refreshLibraryMetadata(library, undefined, false, true);
break; break;
case(Action.Edit): case(Action.Edit):
this.editLibrary(library) this.editLibrary(library)

View File

@ -39,7 +39,7 @@
<div class="input-group"> <div class="input-group">
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text" <input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched"> [class.is-invalid]="formControl.invalid && formControl.touched">
<button class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button> <button type="button" class="btn btn-outline-secondary" (click)="resetBaseUrl()">{{t('reset')}}</button>
</div> </div>
@if(settingsForm.dirty || settingsForm.touched) { @if(settingsForm.dirty || settingsForm.touched) {
@ -64,7 +64,7 @@
<div class="input-group"> <div class="input-group">
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text" <input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched"> [class.is-invalid]="formControl.invalid && formControl.touched">
<button class="btn btn-outline-secondary" (click)="resetIPAddresses()">{{t('reset')}}</button> <button type="button" class="btn btn-outline-secondary" (click)="resetIPAddresses()">{{t('reset')}}</button>
</div> </div>
@if(settingsForm.dirty || settingsForm.touched) { @if(settingsForm.dirty || settingsForm.touched) {

View File

@ -1,31 +1,59 @@
.content-wrapper { .content-wrapper {
padding: 0 10px 0; padding: 0 10px 0;
height: 100%; height: calc(var(--vh)* 100 - var(--nav-offset));
} }
.companion-bar { .companion-bar {
transition: all var(--side-nav-companion-bar-transistion); transition: all var(--side-nav-companion-bar-transistion);
margin-left: 40px; margin-left: 40px;
overflow-y: auto;
overflow-x: hidden;
height: calc(var(--vh)* 100 - var(--nav-offset));
width: 100%;
&::-webkit-scrollbar {
background-color: transparent; /*make scrollbar space invisible */
width: inherit;
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /*makes it invisible when not hovering*/
}
&:hover {
&::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.3); /*On hover, it will turn grey*/
}
}
} }
.companion-bar-collapsed { .companion-bar-collapsed {
margin-left: 0 !important; margin-left: 0 !important;
} }
.companion-bar-content { .companion-bar-content {
margin-left: 190px; margin-left: 190px;
width: auto; mask-image: linear-gradient(to bottom, transparent, black 0%, black 96%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 96%, transparent 100%);
width: calc(100% - 190px);
} }
@media (max-width: 576px) { @media (max-width: 768px) {
::ng-deep html {
height: 100dvh !important;
}
.container-fluid { .container-fluid {
padding: 0; padding: 0;
} }
.content-wrapper { .content-wrapper {
padding: 0 5px 0;
overflow: hidden; overflow: hidden;
height: calc(var(--vh)*100 - var(--nav-offset)); height: calc(var(--vh)* 100 - var(--nav-mobile-offset));
padding: 0 10px 0;
&.closed { &.closed {
overflow: auto; overflow: auto;
@ -35,11 +63,27 @@
.companion-bar { .companion-bar {
margin-left: 0; margin-left: 0;
padding-left: 0; padding-left: 0;
width: calc(100vw - 30px);
padding-top: 20px;
height: calc(100dvh - var(--nav-mobile-offset));
&::-webkit-scrollbar {
width: inherit;
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /*makes it invisible when not hovering*/
}
&:hover {
&::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.3); /*On hover, it will turn grey*/
}
}
} }
.companion-bar-content { .companion-bar-content {
margin-left: 0; margin-left: 0;
width: auto;
} }
} }
@ -67,7 +111,7 @@
height: 100vh; height: 100vh;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background-color: #121212; background-color: var(--bs-body-bg);
filter: blur(20px); filter: blur(20px);
object-fit: contain; object-fit: contain;
transform: scale(1.1); transform: scale(1.1);
@ -80,4 +124,3 @@
height: 113vh; height: 113vh;
} }
} }

View File

@ -4,7 +4,6 @@ import {
DestroyRef, DestroyRef,
HostListener, HostListener,
inject, inject,
Inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import {NavigationStart, Router, RouterOutlet} from '@angular/router'; import {NavigationStart, Router, RouterOutlet} from '@angular/router';
@ -46,11 +45,12 @@ export class AppComponent implements OnInit {
private readonly ngbModal = inject(NgbModal); private readonly ngbModal = inject(NgbModal);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly themeService = inject(ThemeService); private readonly themeService = inject(ThemeService);
private readonly document = inject(DOCUMENT);
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
constructor(ratingConfig: NgbRatingConfig, @Inject(DOCUMENT) private document: Document, modalConfig: NgbModalConfig) { constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) {
modalConfig.fullscreen = 'md'; modalConfig.fullscreen = 'md';
@ -80,7 +80,6 @@ export class AppComponent implements OnInit {
const currentRoute = this.router.routerState; const currentRoute = this.router.routerState;
await this.router.navigateByUrl(currentRoute.snapshot.url, { skipLocationChange: true }); await this.router.navigateByUrl(currentRoute.snapshot.url, { skipLocationChange: true });
} }
}); });
@ -106,6 +105,7 @@ export class AppComponent implements OnInit {
this.themeService.setColorScape(''); this.themeService.setColorScape('');
} }
setCurrentUser() { setCurrentUser() {
const user = this.accountService.getUserFromLocalStorage(); const user = this.accountService.getUserFromLocalStorage();
this.accountService.setCurrentUser(user); this.accountService.setCurrentUser(user);
@ -114,8 +114,6 @@ export class AppComponent implements OnInit {
// Bootstrap anything that's needed // Bootstrap anything that's needed
this.themeService.getThemes().subscribe(); this.themeService.getThemes().subscribe();
this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe(); this.libraryService.getLibraryNames().pipe(take(1), shareReplay({refCount: true, bufferSize: 1})).subscribe();
// On load, make an initial call for valid license
this.accountService.hasValidLicense().subscribe();
// Every hour, have the UI check for an update. People seriously stay out of date // Every hour, have the UI check for an update. People seriously stay out of date
interval(2* 60 * 60 * 1000) // 2 hours in milliseconds interval(2* 60 * 60 * 1000) // 2 hours in milliseconds

View File

@ -629,7 +629,7 @@ export class EditSeriesModalComponent implements OnInit {
await this.actionService.refreshSeriesMetadata(this.series); await this.actionService.refreshSeriesMetadata(this.series);
break; break;
case Action.GenerateColorScape: case Action.GenerateColorScape:
await this.actionService.refreshSeriesMetadata(this.series, undefined, false); await this.actionService.refreshSeriesMetadata(this.series, undefined, false, true);
break; break;
case Action.AnalyzeFiles: case Action.AnalyzeFiles:
this.actionService.analyzeFilesForSeries(this.series); this.actionService.analyzeFilesForSeries(this.series);

View File

@ -1,121 +0,0 @@
<ng-container *transloco="let t; read: 'card-detail-drawer'">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
<span class="modal-title" id="modal-basic-title">
<app-entity-title [libraryType]="libraryType" [entity]="data" [seriesName]="parentName"></app-entity-title>
</span>
</h5>
<button type="button" class="btn-close text-reset" aria-label="Close" (click)="activeOffcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body pb-3">
<div class="d-flex">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="vertical" style="max-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General]">
<a ngbNavLink>{{t(tabs[TabID.General].title)}}</a>
<ng-template ngbNavContent>
<div class="container-fluid" style="overflow: auto">
<div class="row g-0">
<div class="d-none d-md-block col-md-2 col-lg-1">
<app-image class="me-2" width="74px" [imageUrl]="coverImageUrl"></app-image>
</div>
<div class="col-md-10 col-lg-11">
<ng-container *ngIf="summary.length > 0; else noSummary">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</ng-container>
<ng-template #noSummary>
{{t('no-summary')}}
</ng-template>
</div>
</div>
<app-entity-info-cards [entity]="data" [libraryId]="libraryId"></app-entity-info-cards>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Metadata]">
<a ngbNavLink>{{t(tabs[TabID.Metadata].title)}}</a>
<ng-template ngbNavContent>
<app-chapter-metadata-detail [chapter]="chapter"></app-chapter-metadata-detail>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Progress]">
<a ngbNavLink>{{t(tabs[TabID.Progress].title)}}</a>
<ng-template ngbNavContent>
<app-edit-chapter-progress [chapter]="chapter"></app-edit-chapter-progress>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="(accountService.isAdmin$ | async) === false">
<a ngbNavLink>{{t(tabs[TabID.Cover].title)}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateCoverImageIndex($event)"
(selectedBase64Url)="applyCoverImage($event)" [showReset]="chapter.coverImageLocked"
(resetClicked)="resetCoverImage()"></app-cover-image-chooser>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(accountService.isAdmin$ | async) === false">
<a ngbNavLink>{{t(tabs[TabID.Files].title)}}</a>
<ng-template ngbNavContent>
@if (!utilityService.isChapter(data)) {
<h4>{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
}
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" [title]="t('read')">
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span>
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.minNumber !== LooseLeafOrSpecialNumber; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
</span>
</span>
<ng-template #specialHeader>{{t('files')}}</ng-template>
</h5>
<ul class="list-group">
@for (file of chapter.files; track file.id) {
<li class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
{{t('pages')}} {{file.pages | number:''}}
</div>
@if (data.hasOwnProperty('created')) {
<div class="col">
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
</div>
}
<div class="col">
{{t('size')}} {{file.bytes | bytes}}
</div>
</div>
</li>
}
</ul>
</div>
</li>
</ul>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
</div>
</ng-container>

View File

@ -1,20 +0,0 @@
.hide-if-empty:empty {
display: none !important;
}
.offcanvas-body {
overflow: auto;
}
.offcanvas-header {
padding: 1rem 1rem 0;
}
.tab-content {
overflow: auto;
height: calc(40vh - (46px + 1rem)); // drawer height - offcanvas heading height
}
.h6 {
font-weight: 600;
}

View File

@ -1,278 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
inject,
Input,
OnInit
} from '@angular/core';
import { Router } from '@angular/router';
import {
NgbActiveOffcanvas,
NgbNav,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet
} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Observable, of } from 'rxjs';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import {Chapter, LooseLeafOrDefaultNumber} from 'src/app/_models/chapter';
import { Device } from 'src/app/_models/device/device';
import { LibraryType } from 'src/app/_models/library/library';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Volume } from 'src/app/_models/volume';
import { AccountService } from 'src/app/_services/account.service';
import { ActionItem, ActionFactoryService, Action } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { UploadService } from 'src/app/_services/upload.service';
import {CommonModule} from "@angular/common";
import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {ImageComponent} from "../../shared/image/image.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {CoverImageChooserComponent} from "../cover-image-chooser/cover-image-chooser.component";
import {ChapterMetadataDetailComponent} from "../chapter-metadata-detail/chapter-metadata-detail.component";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {EditChapterProgressComponent} from "../edit-chapter-progress/edit-chapter-progress.component";
import {CarouselTabsComponent} from "../../carousel/_components/carousel-tabs/carousel-tabs.component";
import {CarouselTabComponent} from "../../carousel/_components/carousel-tab/carousel-tab.component";
enum TabID {
General = 0,
Metadata = 1,
Cover = 2,
Progress = 3,
Files = 4
}
@Component({
selector: 'app-card-detail-drawer',
standalone: true,
imports: [CommonModule, EntityTitleComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, ImageComponent, ReadMoreComponent, EntityInfoCardsComponent, CoverImageChooserComponent, ChapterMetadataDetailComponent, CardActionablesComponent, DefaultDatePipe, BytesPipe, NgbNavOutlet, BadgeExpanderComponent, TagBadgeComponent, PersonBadgeComponent, TranslocoDirective, EditChapterProgressComponent, CarouselTabsComponent, CarouselTabComponent],
templateUrl: './card-detail-drawer.component.html',
styleUrls: ['./card-detail-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardDetailDrawerComponent implements OnInit {
protected readonly utilityService = inject(UtilityService);
protected readonly imageService = inject(ImageService);
private readonly uploadService = inject(UploadService);
private readonly toastr = inject(ToastrService);
protected readonly accountService = inject(AccountService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
private readonly router = inject(Router);
private readonly libraryService = inject(LibraryService);
private readonly readerService = inject(ReaderService);
protected readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly downloadService = inject(DownloadService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
protected readonly LibraryType = LibraryType;
protected readonly TabID = TabID;
protected readonly LooseLeafOrSpecialNumber = LooseLeafOrDefaultNumber;
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() libraryId: number = 0;
@Input({required: true}) data!: Volume | Chapter;
/**
* If this is a volume, this will be first chapter for said volume.
*/
chapter!: Chapter;
isChapter = false;
chapters: Chapter[] = [];
imageUrls: Array<string> = [];
/**
* Cover image for the entity
*/
coverImageUrl!: string;
isAdmin$: Observable<boolean> = of(false);
actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
libraryType: LibraryType = LibraryType.Manga;
tabs = [
{title: 'general-tab', disabled: false},
{title: 'metadata-tab', disabled: false},
{title: 'cover-tab', disabled: false},
{title: 'progress-tab', disabled: false},
{title: 'info-tab', disabled: false}
];
active = this.tabs[0];
summary: string = '';
downloadInProgress: boolean = false;
ngOnInit(): void {
this.imageUrls = this.chapters.map(c => this.imageService.getChapterCoverImage(c.id));
this.isChapter = this.utilityService.isChapter(this.data);
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
if (this.isChapter) {
this.coverImageUrl = this.imageService.getChapterCoverImage(this.data.id);
this.summary = this.utilityService.asChapter(this.data).summary || '';
this.chapters.push(this.data as Chapter);
} else {
this.coverImageUrl = this.imageService.getVolumeCoverImage(this.data.id);
this.summary = this.utilityService.asVolume(this.data).chapters[0].summary || '';
this.chapters.push(...(this.data as Volume).chapters);
}
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
.filter(item => item.action !== Action.Edit);
this.chapterActions.push({title: 'read', description: '', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
if (this.isChapter) {
const chapter = this.utilityService.asChapter(this.data);
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
} else {
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, this.chapters[0]);
}
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
this.cdRef.markForCheck();
});
const collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
this.imageUrls = this.chapters.map(c => this.imageService.getChapterCoverImage(c.id));
this.cdRef.markForCheck();
}
close() {
this.activeOffcanvas.close();
}
formatChapterNumber(chapter: Chapter) {
if (chapter.minNumber === LooseLeafOrDefaultNumber) {
return '1';
}
return chapter.range + '';
}
performAction(action: ActionItem<any>, chapter: Chapter) {
if (typeof action.callback === 'function') {
action.callback(action, chapter);
}
}
applyCoverImage(coverUrl: string) {
this.uploadService.updateChapterCoverImage(this.chapter.id, coverUrl).subscribe(() => {});
}
updateCoverImageIndex(selectedIndex: number) {
if (selectedIndex <= 0) return;
this.applyCoverImage(this.imageUrls[selectedIndex]);
}
resetCoverImage() {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info(translate('toasts.regen-cover'));
});
}
markChapterAsRead(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
markChapterAsUnread(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
case(Action.MarkAsRead):
this.markChapterAsRead(chapter);
break;
case(Action.MarkAsUnread):
this.markChapterAsUnread(chapter);
break;
case(Action.AddToReadingList):
this.actionService.addChapterToReadingList(chapter, this.seriesId);
break;
case (Action.IncognitoRead):
this.readChapter(chapter, true);
break;
case (Action.Download):
this.download(chapter);
break;
case (Action.Read):
this.readChapter(chapter, false);
break;
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([chapter.id], device);
break;
}
default:
break;
}
}
readChapter(chapter: Chapter, incognito: boolean = false) {
if (chapter.pages === 0) {
this.toastr.error(translate('toasts.no-pages'));
return;
}
const params = this.readerService.getQueryParamsObject(incognito, false);
this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, chapter.id, chapter.files[0].format), {queryParams: params});
this.close();
}
download(chapter: Chapter) {
if (this.downloadInProgress) {
this.toastr.info(translate('toasts.download-in-progress'));
return;
}
this.downloadInProgress = true;
this.cdRef.markForCheck();
this.downloadService.download('chapter', chapter, (d) => {
if (d) return;
this.downloadInProgress = false;
this.cdRef.markForCheck();
});
}
}

View File

@ -32,6 +32,8 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
align-items: start; align-items: start;
} }
@media (max-width: 576px) { @media (max-width: 576px) {
@ -92,13 +94,33 @@
.virtual-scroller, virtual-scroller { .virtual-scroller, virtual-scroller {
width: 100%; width: 100%;
height: calc(var(--vh) * 100 - 173px); height: calc(var(--vh) * 100 - 143px);
mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 0%, black 95%, transparent 100%);
overflow: auto;
} }
virtual-scroller.empty { virtual-scroller.empty {
display: none; display: none;
} }
.vertical.selfScroll {
&::-webkit-scrollbar {
width: inherit;
}
&::-webkit-scrollbar-thumb {
background-color: transparent; /*makes it invisible when not hovering*/
}
&:hover {
&::-webkit-scrollbar-thumb {
background-color: rgba(255,255,255,0.3); /*On hover, it will turn grey*/
}
}
}
h2 { h2 {
display: inline-block; display: inline-block;
word-break: break-all; word-break: break-all;

View File

@ -44,14 +44,19 @@
} }
<div class="card-overlay"></div> <div class="card-overlay"></div>
@if (overlayInformation | safeHtml; as info) {
@if (info) { @if (showReadButton) {
<div class="overlay-information {{centerOverlay ? 'overlay-information--centered' : ''}}"> <div class="series overlay-information">
<div class="position-relative"> <div class="overlay-information--centered">
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="info" placement="top" [innerHTML]="info"></span> <span class="card-title library mx-auto" style="width: auto;">
</div> <span (click)="clickRead($event)">
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
</span>
</div> </div>
} </div>
} }
</div> </div>
<div class="card-body meta-title"> <div class="card-body meta-title">

View File

@ -134,13 +134,17 @@ export class CardItemComponent implements OnInit {
*/ */
@Input() count: number = 0; @Input() count: number = 0;
/** /**
* Additional information to show on the overlay area. Will always render. * Show a read button. Emits on (readClicked)
*/ */
@Input() overlayInformation: string = ''; @Input() showReadButton: boolean = false;
/** /**
* If overlay is enabled, should the text be centered or not * If overlay is enabled, should the text be centered or not
*/ */
@Input() centerOverlay = false; @Input() centerOverlay = false;
/**
* Will generate a button to instantly read
*/
@Input() hasReadButton = false;
/** /**
* Event emitted when item is clicked * Event emitted when item is clicked
*/ */
@ -149,6 +153,7 @@ export class CardItemComponent implements OnInit {
* When the card is selected. * When the card is selected.
*/ */
@Output() selection = new EventEmitter<boolean>(); @Output() selection = new EventEmitter<boolean>();
@Output() readClicked = new EventEmitter<Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter>();
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>; @ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
/** /**
* Library name item belongs to * Library name item belongs to
@ -229,9 +234,10 @@ export class CardItemComponent implements OnInit {
const nextDate = (this.entity as NextExpectedChapter); const nextDate = (this.entity as NextExpectedChapter);
const tokens = nextDate.title.split(':'); const tokens = nextDate.title.split(':');
this.overlayInformation = ` // this.overlayInformation = `
<i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i> // <i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i>
<div>${tokens[0]}</div><div>${tokens[1]}</div>`; // <div>${tokens[0]}</div><div>${tokens[1]}</div>`;
// // todo: figure out where this caller is
this.centerOverlay = true; this.centerOverlay = true;
if (nextDate.expectedDate) { if (nextDate.expectedDate) {
@ -387,4 +393,11 @@ export class CardItemComponent implements OnInit {
// return a.isAllowed(a, this.entity); // return a.isAllowed(a, this.entity);
// }); // });
} }
clickRead(event: any) {
event.stopPropagation();
if (this.bulkSelectionService.hasSelections()) return;
this.readClicked.emit(this.entity);
}
} }

View File

@ -1,132 +0,0 @@
<ng-container *transloco="let t; read: 'chapter-metadata-detail'">
<ng-container *ngIf="chapter !== undefined">
<span *ngIf="chapter.writers.length === 0 && chapter.coverArtists.length === 0
&& chapter.pencillers.length === 0 && chapter.inkers.length === 0
&& chapter.colorists.length === 0 && chapter.letterers.length === 0
&& chapter.editors.length === 0 && chapter.publishers.length === 0
&& chapter.characters.length === 0 && chapter.translators.length === 0
&& chapter.imprints.length === 0 && chapter.locations.length === 0
&& chapter.teams.length === 0">
{{t('no-data')}}
</span>
<div class="container-flex row row-cols-auto row-cols-lg-5 g-2 g-lg-3 me-0 mt-2">
<div class="col-auto mt-2" *ngIf="chapter.writers && chapter.writers.length > 0">
<h6>{{t('writers-title')}}</h6>
<app-badge-expander [items]="chapter.writers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.coverArtists && chapter.coverArtists.length > 0">
<h6>{{t('cover-artists-title')}}</h6>
<app-badge-expander [items]="chapter.coverArtists">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.pencillers && chapter.pencillers.length > 0">
<h6>{{t('pencillers-title')}}</h6>
<app-badge-expander [items]="chapter.pencillers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.inkers && chapter.inkers.length > 0">
<h6>{{t('inkers-title')}}</h6>
<app-badge-expander [items]="chapter.inkers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.colorists && chapter.colorists.length > 0">
<h6>{{t('colorists-title')}}</h6>
<app-badge-expander [items]="chapter.colorists">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.letterers && chapter.letterers.length > 0">
<h6>{{t('letterers-title')}}</h6>
<app-badge-expander [items]="chapter.letterers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.editors && chapter.editors.length > 0">
<h6>{{t('editors-title')}}</h6>
<app-badge-expander [items]="chapter.editors">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.publishers && chapter.publishers.length > 0">
<h6>{{t('publishers-title')}}</h6>
<app-badge-expander [items]="chapter.publishers">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.imprints && chapter.imprints.length > 0">
<h6>{{t('imprints-title')}}</h6>
<app-badge-expander [items]="chapter.imprints">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.characters && chapter.characters.length > 0">
<h6>{{t('characters-title')}}</h6>
<app-badge-expander [items]="chapter.characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.teams && chapter.teams.length > 0">
<h6>{{t('teams-title')}}</h6>
<app-badge-expander [items]="chapter.teams">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.locations && chapter.locations.length > 0">
<h6>{{t('locations-title')}}</h6>
<app-badge-expander [items]="chapter.locations">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.translators && chapter.translators.length > 0">
<h6>{{t('translators-title')}}</h6>
<app-badge-expander [items]="chapter.translators">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
</div>
</ng-container>
</ng-container>

View File

@ -1,18 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import {CommonModule} from "@angular/common";
import {BadgeExpanderComponent} from "../../shared/badge-expander/badge-expander.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {Chapter} from "../../_models/chapter";
@Component({
selector: 'app-chapter-metadata-detail',
standalone: true,
imports: [CommonModule, BadgeExpanderComponent, PersonBadgeComponent, TranslocoDirective],
templateUrl: './chapter-metadata-detail.component.html',
styleUrls: ['./chapter-metadata-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChapterMetadataDetailComponent {
@Input() chapter: Chapter | undefined;
}

View File

@ -1,130 +0,0 @@
<ng-container *transloco="let t; read: 'entity-info-cards'">
<div class="mt-3 mb-3">
<div class="row g-0" *ngIf="chapter ">
<!-- Tags and Characters are used a lot of Hentai and Doujinshi type content, so showing in list item has value add on first glance -->
<app-metadata-detail [tags]="chapter.tags" [libraryId]="libraryId" [queryParam]="FilterField.Tags" heading="Tags">
<ng-template #titleTemplate let-item>{{item.title}}</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="chapter.characters" [libraryId]="libraryId" [queryParam]="FilterField.Characters" heading="Characters">
<ng-template #titleTemplate let-item>{{item.name}}</ng-template>
</app-metadata-detail>
</div>
<div class="row g-0">
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('release-date-tooltip')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-date-title')">
{{chapter.releaseDate | date:'shortDate' | defaultDate}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.ageRating !== AgeRating.Unknown">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('age-rating-title')" [clickable]="false" fontClasses="fas fa-eye" [title]="t('age-rating-title')">
{{chapter.ageRating | ageRating}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="totalPages > 0">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{t('pages-count', {num: totalPages | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{t('words-count', {num: totalWordCount | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title [label]="t('read-time-title')" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">{{t('less-than-hour')}}</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} {{readingTime.minHours > 1 ? t('hours') : t('hour')}}
</ng-template>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && chapter.createdUtc && chapter.createdUtc !== '' && (chapter.createdUtc | date: 'shortDate') !== '1/1/01'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('date-added-title')" [clickable]="false" fontClasses="fa-solid fa-file-import" [title]="t('date-added-title')">
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && size > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('size-title')" [clickable]="false" fontClasses="fa-solid fa-scale-unbalanced" [title]="t('size-title')">
{{size | bytes}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('id-title')" [clickable]="false" fontClasses="fa-solid fa-fingerprint" [title]="t('id-title')">
{{entity.id}}
</app-icon-and-title>
</div>
<ng-container *ngIf="WebLinks.length > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('links-title')" [clickable]="false" fontClasses="fa-solid fa-link" [title]="t('links-title')">
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.isbn.length > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('isbn-title')" [clickable]="false" fontClasses="fa-solid fa-barcode" [title]="t('isbn-title')">
{{chapter.isbn}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="(chapter.lastReadingProgress | date: 'shortDate') !== '1/1/01'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('last-read-title')" [clickable]="false" fontClasses="fa-regular fa-clock" [ngbTooltip]="chapter.lastReadingProgress | date: 'medium'">
{{chapter.lastReadingProgress | date: 'shortDate'}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="isChapter">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title [label]="t('sort-order-title')" [clickable]="false" fontClasses="fa-solid fa-arrow-down-1-9" [title]="t('sort-order-title')">
{{chapter.sortOrder}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>

View File

@ -1,116 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnInit,
inject,
} from '@angular/core';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { HourEstimateRange } from 'src/app/_models/series-detail/hour-estimate-range';
import { MangaFormat } from 'src/app/_models/manga-format';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { Volume } from 'src/app/_models/volume';
import { SeriesService } from 'src/app/_services/series.service';
import { ImageService } from 'src/app/_services/image.service';
import {CommonModule} from "@angular/common";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {BytesPipe} from "../../_pipes/bytes.pipe";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
import {TranslocoModule} from "@jsverse/transloco";
import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ImageComponent} from "../../shared/image/image.component";
@Component({
selector: 'app-entity-info-cards',
standalone: true,
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe,
AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule,
UtcToLocalTimePipe, ImageComponent],
templateUrl: './entity-info-cards.component.html',
styleUrls: ['./entity-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EntityInfoCardsComponent implements OnInit {
protected readonly AgeRating = AgeRating;
protected readonly MangaFormat = MangaFormat;
protected readonly FilterField = FilterField;
public readonly imageService = inject(ImageService);
@Input({required: true}) entity!: Volume | Chapter;
@Input({required: true}) libraryId!: number;
/**
* Hide more system based fields, like id or Date Added
*/
@Input() showExtendedProperties: boolean = true;
isChapter = false;
chapter!: Chapter;
ageRating!: string;
totalPages: number = 0;
totalWordCount: number = 0;
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
size: number = 0;
get WebLinks() {
if (this.chapter.webLinks === '') return [];
return this.chapter.webLinks.split(',');
}
constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
this.chapter = this.utilityService.isChapter(this.entity) ? (this.entity as Chapter) : (this.entity as Volume).chapters[0];
if (this.isChapter) {
this.size = this.utilityService.asChapter(this.entity).files.reduce((sum, v) => sum + v.bytes, 0);
} else {
this.size = this.utilityService.asVolume(this.entity).chapters.reduce((sum1, chapter) => {
return sum1 + chapter.files.reduce((sum2, file) => {
return sum2 + file.bytes;
}, 0);
}, 0);
}
this.totalPages = this.chapter.pages;
if (!this.isChapter) {
this.totalPages = this.utilityService.asVolume(this.entity).pages;
}
this.totalWordCount = this.chapter.wordCount;
if (!this.isChapter) {
this.totalWordCount = this.utilityService.asVolume(this.entity).chapters.map(c => c.wordCount).reduce((sum, d) => sum + d);
}
if (this.isChapter) {
this.readingTime.minHours = this.chapter.minHoursToRead;
this.readingTime.maxHours = this.chapter.maxHoursToRead;
this.readingTime.avgHours = this.chapter.avgHoursToRead;
} else {
const vol = this.utilityService.asVolume(this.entity);
this.readingTime.minHours = vol.minHoursToRead;
this.readingTime.maxHours = vol.maxHoursToRead;
this.readingTime.avgHours = vol.avgHoursToRead;
}
this.cdRef.markForCheck();
}
}

View File

@ -2,9 +2,12 @@
@switch (libraryType) { @switch (libraryType) {
@case (LibraryType.Comic) { @case (LibraryType.Comic) {
@if (titleName !== '' && prioritizeTitleName) { @if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('issue-num') + ' ' + number + ' - ' }}
}
{{titleName}} {{titleName}}
} @else { } @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') { @if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}} {{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
} }
@ -14,9 +17,12 @@
@case (LibraryType.ComicVine) { @case (LibraryType.ComicVine) {
@if (titleName !== '' && prioritizeTitleName) { @if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('issue-num') + ' ' + number + ' - ' }}
}
{{titleName}} {{titleName}}
} @else { } @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') { @if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}} {{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
} }
@ -26,12 +32,15 @@
@case (LibraryType.Manga) { @case (LibraryType.Manga) {
@if (titleName !== '' && prioritizeTitleName) { @if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('chapter') + ' ' + number + ' - ' }}
}
{{titleName}} {{titleName}}
} @else { } @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') { @if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}} {{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
} }
{{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}} {{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}
} }
} }

View File

@ -28,12 +28,15 @@ export class EntityTitleComponent implements OnInit {
* Library type for which the entity belongs * Library type for which the entity belongs
*/ */
@Input() libraryType: LibraryType = LibraryType.Manga; @Input() libraryType: LibraryType = LibraryType.Manga;
@Input() seriesName: string = '';
@Input({required: true}) entity!: Volume | Chapter; @Input({required: true}) entity!: Volume | Chapter;
/** /**
* When generating the title, should this prepend 'Volume number' before the Chapter wording * When generating the title, should this prepend 'Volume number' before the Chapter wording
*/ */
@Input() includeVolume: boolean = false; @Input() includeVolume: boolean = false;
/**
* When generating the title, should this prepend 'Chapter number' before the Chapter titlename
*/
@Input() includeChapter: boolean = false;
/** /**
* When a titleName (aka a title) is available on the entity, show it over Volume X Chapter Y * When a titleName (aka a title) is available on the entity, show it over Volume X Chapter Y
*/ */

View File

@ -1,17 +0,0 @@
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5 class="mb-0">
<ng-content select="[title]"></ng-content>
</h5>
@if (summary && summary.length > 0) {
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
}
</div>
</div>
</div>

View File

@ -1,6 +0,0 @@
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}

View File

@ -1,31 +0,0 @@
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ImageComponent} from "../../shared/image/image.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
@Component({
selector: 'app-external-list-item',
standalone: true,
imports: [CommonModule, ImageComponent, NgbProgressbar, NgbTooltip, ReadMoreComponent],
templateUrl: './external-list-item.component.html',
styleUrls: ['./external-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExternalListItemComponent {
/**
* Image to show
*/
@Input() imageUrl: string = '';
/**
* Size of the Image Height. Defaults to 232.91px.
*/
@Input() imageHeight: string = '232.91px';
/**
* Size of the Image Width Defaults to 160px.
*/
@Input() imageWidth: string = '160px';
@Input() summary: string | null = '';
}

View File

@ -1,40 +0,0 @@
<ng-container *transloco="let t; read: 'list-item'">
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [styles]="{'max-height': '200px'}" [width]="imageWidth"></app-image>
<div class="not-read-badge" *ngIf="pagesRead === 0 && totalPages > 0"></div>
<span class="download">
<app-download-indicator [download$]="download$"></app-download-indicator>
</span>
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages"
ngbTooltip="{{(pagesRead / totalPages) | number:'1.0-1'}}% Read">
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
</div>
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5 class="mb-0">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="seriesName" iconClass="fa-ellipsis-v"></app-card-actionables>
<ng-content select="[title]"></ng-content>
<button class="btn btn-primary float-end" (click)="read.emit()">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-sm-inline-block">{{t('read')}}</span>
</button>
</h5>
<h6 class="text-muted" [ngClass]="{'subtitle-with-actionables' : actions.length > 0}" *ngIf="Title !== '' && showTitle">{{Title}}</h6>
<ng-container *ngIf="summary.length > 0">
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [blur]="pagesRead === 0 && blur" [maxLength]="250"></app-read-more>
</div>
</ng-container>
<div class="ps-2 d-none d-md-inline-block" style="width: 100%">
<app-entity-info-cards [entity]="entity" [libraryId]="libraryId" [showExtendedProperties]="ShowExtended"></app-entity-info-cards>
</div>
</div>
</div>
</div>
</ng-container>

View File

@ -1,40 +0,0 @@
// with summary and cards, we have a height of 220px, we might want to default to 220px and let it grow from there to help with virtualization
.download {
width: 80px;
height: 80px;
position: absolute;
top: 55px;
left: 20px;
}
.progress-banner {
height: 5px;
.progress {
color: var(--card-progress-bar-color);
background-color: transparent;
}
}
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}
.not-read-badge {
position: absolute;
top: 8px;
left: 110px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
border-color: transparent var(--primary-color) transparent transparent;
}
.subtitle-with-actionables {
font-size: 0.75rem;
word-break: break-all;
}

View File

@ -1,161 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
EventEmitter,
inject,
Input,
OnInit,
Output
} from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { map, Observable } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadEvent, DownloadService } from 'src/app/shared/_services/download.service';
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library/library';
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {CommonModule} from "@angular/common";
import {ImageComponent} from "../../shared/image/image.component";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityInfoCardsComponent} from "../entity-info-cards/entity-info-cards.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@Component({
selector: 'app-list-item',
standalone: true,
imports: [CommonModule, ReadMoreComponent, ImageComponent, DownloadIndicatorComponent, EntityInfoCardsComponent, CardActionablesComponent, NgbProgressbar, NgbTooltip, TranslocoDirective],
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListItemComponent implements OnInit {
/**
* Volume or Chapter to render
*/
@Input({required: true}) entity!: Volume | Chapter;
@Input({required: true}) libraryId!: number;
/**
* Image to show
*/
@Input() imageUrl: string = '';
/**
* Actions to show
*/
@Input() actions: ActionItem<any>[] = []; // Volume | Chapter
/**
* Library type to help with formatting title
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
/**
* Name of the Series to show under the title
*/
@Input() seriesName: string = '';
/**
* Size of the Image Height. Defaults to 232.91px.
*/
@Input() imageHeight: string = '232.91px';
/**
* Size of the Image Width Defaults to 160px.
*/
@Input() imageWidth: string = '160px';
@Input() seriesLink: string = '';
@Input() pagesRead: number = 0;
@Input() totalPages: number = 0;
@Input() relation: RelationKind | undefined = undefined;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@Input() includeVolume: boolean = false;
/**
* Show's the title if available on entity
*/
@Input() showTitle: boolean = true;
/**
* Blur the summary for the list item
*/
@Input() blur: boolean = false;
@Output() read: EventEmitter<void> = new EventEmitter<void>();
private readonly destroyRef = inject(DestroyRef);
actionInProgress: boolean = false;
summary: string = '';
isChapter: boolean = false;
download$: Observable<DownloadEvent | null> | null = null;
downloadInProgress: boolean = false;
get Title() {
if (this.isChapter) return (this.entity as Chapter).titleName;
return '';
}
get ShowExtended() {
return this.utilityService.getActiveBreakpoint() === Breakpoint.Desktop;
}
protected readonly Breakpoint = Breakpoint;
constructor(public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
if (this.isChapter) {
this.summary = this.utilityService.asChapter(this.entity).summary || '';
} else {
this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || '';
}
this.cdRef.markForCheck();
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
if(this.utilityService.isVolume(this.entity)) return events.find(e => e.entityType === 'volume' && e.subTitle === this.downloadService.downloadSubtitle('volume', (this.entity as Volume))) || null;
if(this.utilityService.isChapter(this.entity)) return events.find(e => e.entityType === 'chapter' && e.subTitle === this.downloadService.downloadSubtitle('chapter', (this.entity as Chapter))) || null;
return null;
}));
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
if (this.downloadInProgress) {
this.toastr.info(translate('toasts.download-in-progress'));
return;
}
const statusUpdate = (d: Download | undefined) => {
if (d) return;
this.downloadInProgress = false;
};
if (this.utilityService.isVolume(this.entity)) {
const volume = this.utilityService.asVolume(this.entity);
this.downloadService.download('volume', volume, statusUpdate);
} else if (this.utilityService.isChapter(this.entity)) {
const chapter = this.utilityService.asChapter(this.entity);
this.downloadService.download('chapter', chapter, statusUpdate);
}
return; // Don't propagate the download from a card
}
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
}

View File

@ -6,21 +6,22 @@
<div class="card-overlay"></div> <div class="card-overlay"></div>
</div> </div>
<ng-container *ngIf="entity.title | safeHtml as info"> @if (entity.title | safeHtml; as info) {
<div class="card-body meta-title" *ngIf="info !== ''"> @if (info !== '') {
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center"> <div class="card-body meta-title">
<div class="card-content d-flex flex-column pt-2 pb-2 justify-content-center align-items-center text-center">
<div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>Upcoming</div> <div class="upcoming-header"><i class="fa-regular fa-clock me-1" aria-hidden="true"></i>Upcoming</div>
<span [innerHTML]="info"></span> <span [innerHTML]="info"></span>
</div>
</div> </div>
</div> }
</ng-container> }
<div class="card-title-container">
<span class="card-title" tabindex="0">
{{title}}
</span>
</div>
<div class="card-title-container">
<span class="card-title" tabindex="0">
{{title}}
</span>
</div> </div>
</div>

View File

@ -1,5 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -9,7 +8,7 @@ import {translate} from "@jsverse/transloco";
@Component({ @Component({
selector: 'app-next-expected-card', selector: 'app-next-expected-card',
standalone: true, standalone: true,
imports: [CommonModule, ImageComponent, SafeHtmlPipe], imports: [ImageComponent, SafeHtmlPipe],
templateUrl: './next-expected-card.component.html', templateUrl: './next-expected-card.component.html',
styleUrl: './next-expected-card.component.scss', styleUrl: './next-expected-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -62,8 +62,9 @@
</div> </div>
<div class="card-title-container"> <div class="card-title-container">
<span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}" tabindex="0"> <span class="card-title" [ngbTooltip]="series.name" id="{{series.id}}">
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{series.id}}"> <app-series-format [format]="series.format"></app-series-format>
<a class="dark-exempt btn-icon ms-1" routerLink="/library/{{libraryId}}/series/{{series.id}}">
{{series.name}} {{series.name}}
</a> </a>
</span> </span>
@ -71,7 +72,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions float-end"> <span class="card-actions float-end">
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables> <app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables>
</span> </span>
} }
</div> </div>

View File

@ -39,6 +39,7 @@ import {BulkSelectionService} from "../bulk-selection.service";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
function deepClone(obj: any): any { function deepClone(obj: any): any {
if (obj === null || typeof obj !== 'object') { if (obj === null || typeof obj !== 'object') {
@ -67,7 +68,7 @@ function deepClone(obj: any): any {
@Component({ @Component({
selector: 'app-series-card', selector: 'app-series-card',
standalone: true, standalone: true,
imports: [CommonModule, CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective], imports: [CommonModule, CardItemComponent, RelationshipPipe, CardActionablesComponent, DefaultValuePipe, DownloadIndicatorComponent, EntityTitleComponent, FormsModule, ImageComponent, NgbProgressbar, NgbTooltip, RouterLink, TranslocoDirective, SeriesFormatComponent],
templateUrl: './series-card.component.html', templateUrl: './series-card.component.html',
styleUrls: ['./series-card.component.scss'], styleUrls: ['./series-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@ -284,7 +285,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
} }
async refreshMetadata(series: Series, forceUpdate = false) { async refreshMetadata(series: Series, forceUpdate = false) {
await this.actionService.refreshSeriesMetadata(series, undefined, forceUpdate); await this.actionService.refreshSeriesMetadata(series, undefined, forceUpdate, forceUpdate);
} }
async scanLibrary(series: Series) { async scanLibrary(series: Series) {

View File

@ -1,126 +0,0 @@
<ng-container *transloco="let t; read: 'series-info-cards'">
<div class="row g-0 mt-3">
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('release-date-title')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-year-tooltip')">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('age-rating-title')" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterField.AgeRating, seriesMetadata.ageRating)" [title]="t('age-rating-title')">
{{this.seriesMetadata.ageRating | ageRating}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('language-title')" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterField.Languages, seriesMetadata.language)" [title]="t('language-title')">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-container>
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title [label]="t('publication-status-title')" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === t('ongoing') ? 'empty' : 'end'}}"
(click)="handleGoTo(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
{{pubStatus}}
</app-icon-and-title>
</ng-container>
</div>
<div class="vr m-2 d-none d-lg-block"></div>
</ng-container>
<ng-container *ngIf="accountService.hasValidLicense$ | async">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('scrobbling-title')" [clickable]="libraryAllowsScrobbling"
fontClasses="fa-solid fa-tower-{{(isScrobbling && libraryAllowsScrobbling) ? 'broadcast' : 'observation'}}"
(click)="toggleScrobbling($event)"
[ngbTooltip]="t('scrobbling-tooltip')">
<ng-container *ngIf="libraryAllowsScrobbling; else noScrobble">
{{ isScrobbling ? t('on') : t('off') }}
</ng-container>
<ng-template #noScrobble>
{{t('disabled')}}
</ng-template>
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series">
<ng-container>
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('format-title')" [clickable]="true"
[fontClasses]="series.format | mangaFormatIcon"
(click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
{{series.format | mangaFormat}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('last-read-title')" [clickable]="false" fontClasses="fa-regular fa-clock" [title]="t('last-read-title')">
{{series.latestReadDate | timeAgo}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{t('words-count', {num: series.wordCount | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-template #showPages>
<div class="d-none d-md-block col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{t('pages-count', {num: series.pages | compactNumber})}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-template>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title [label]="t('read-time-title')" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">{{t('less-than-hour')}}</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} {{readingTime.minHours > 1 ? t('hours') : t('hour')}}
</ng-template>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && readingTimeLeft.avgHours !== 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-xl-auto col-lg-auto col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Time Left" [clickable]="false" fontClasses="fa-solid fa-clock">
{{readingTimeLeft | readTimeLeft}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
</ng-container>

View File

@ -1,144 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
EventEmitter,
inject,
Input,
OnChanges,
OnInit,
Output
} from '@angular/core';
import {debounceTime, filter, map} from 'rxjs';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {UserProgressUpdateEvent} from 'src/app/_models/events/user-progress-update-event';
import {HourEstimateRange} from 'src/app/_models/series-detail/hour-estimate-range';
import {MangaFormat} from 'src/app/_models/manga-format';
import {Series} from 'src/app/_models/series';
import {SeriesMetadata} from 'src/app/_models/metadata/series-metadata';
import {AccountService} from 'src/app/_services/account.service';
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
import {ReaderService} from 'src/app/_services/reader.service';
import {FilterField} from "../../_models/metadata/v2/filter-field";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobblingService} from "../../_services/scrobbling.service";
import {CommonModule} from "@angular/common";
import {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {LanguageNamePipe} from "../../_pipes/language-name.pipe";
import {PublicationStatusPipe} from "../../_pipes/publication-status.pipe";
import {MangaFormatPipe} from "../../_pipes/manga-format.pipe";
import {TimeAgoPipe} from "../../_pipes/time-ago.pipe";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {MangaFormatIconPipe} from "../../_pipes/manga-format-icon.pipe";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {ReadTimeLeftPipe} from "../../_pipes/read-time-left.pipe";
@Component({
selector: 'app-series-info-cards',
standalone: true,
imports: [CommonModule, IconAndTitleComponent, AgeRatingPipe, DefaultValuePipe, LanguageNamePipe, PublicationStatusPipe, MangaFormatPipe, TimeAgoPipe, CompactNumberPipe, MangaFormatIconPipe, NgbTooltip, TranslocoDirective, ReadTimeLeftPipe],
templateUrl: './series-info-cards.component.html',
styleUrls: ['./series-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SeriesInfoCardsComponent implements OnInit, OnChanges {
private readonly destroyRef = inject(DestroyRef);
public readonly utilityService = inject(UtilityService);
private readonly readerService = inject(ReaderService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly messageHub = inject(MessageHubService);
public readonly accountService = inject(AccountService);
private readonly scrobbleService = inject(ScrobblingService);
@Input({required: true}) series!: Series;
@Input({required: true}) seriesMetadata!: SeriesMetadata;
@Input() hasReadingProgress: boolean = false;
@Input() readingTimeLeft: HourEstimateRange | undefined;
/**
* If this should make an API call to request readingTimeLeft
*/
@Input() showReadingTimeLeft: boolean = true;
@Output() goTo: EventEmitter<{queryParamName: FilterField, filter: any}> = new EventEmitter();
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
isScrobbling: boolean = true;
libraryAllowsScrobbling: boolean = true;
protected readonly MangaFormat = MangaFormat;
protected readonly FilterField = FilterField;
constructor() {
// Listen for progress events and re-calculate getTimeLeft
this.messageHub.messages$.pipe(filter(event => event.event === EVENTS.UserProgressUpdate),
map(evt => evt.payload as UserProgressUpdateEvent),
debounceTime(500),
takeUntilDestroyed(this.destroyRef))
.subscribe(updateEvent => {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
if (user === undefined || user.username !== updateEvent.username) return;
if (updateEvent.seriesId !== this.series.id) return;
this.getReadingTimeLeft();
});
});
}
ngOnInit(): void {
if (this.series !== null) {
this.getReadingTimeLeft();
this.readingTime.minHours = this.series.minHoursToRead;
this.readingTime.maxHours = this.series.maxHoursToRead;
this.readingTime.avgHours = this.series.avgHoursToRead;
this.scrobbleService.hasHold(this.series.id).subscribe(res => {
this.isScrobbling = !res;
this.cdRef.markForCheck();
});
this.scrobbleService.libraryAllowsScrobbling(this.series.id).subscribe(res => {
this.libraryAllowsScrobbling = res;
this.cdRef.markForCheck();
});
this.cdRef.markForCheck();
}
}
ngOnChanges() {
this.cdRef.markForCheck();
}
handleGoTo(queryParamName: FilterField, filter: any) {
// Ignore the default case added as this query combo would never be valid
if (filter + '' === '' && queryParamName === FilterField.SeriesName) return;
this.goTo.emit({queryParamName, filter});
}
private getReadingTimeLeft() {
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => {
this.readingTimeLeft = timeLeft;
this.cdRef.markForCheck();
});
}
toggleScrobbling(evt: any) {
evt.stopPropagation();
if (this.isScrobbling) {
this.scrobbleService.addHold(this.series.id).subscribe(() => {
this.isScrobbling = !this.isScrobbling;
this.cdRef.markForCheck();
});
} else {
this.scrobbleService.removeHold(this.series.id).subscribe(() => {
this.isScrobbling = !this.isScrobbling;
this.cdRef.markForCheck();
});
}
}
}

View File

@ -1 +0,0 @@
<ng-content></ng-content>

View File

@ -1,17 +0,0 @@
import {ChangeDetectionStrategy, Component, ContentChild, Input, TemplateRef} from '@angular/core';
import {TabId} from "../carousel-tabs/carousel-tabs.component";
@Component({
selector: 'app-carousel-tab',
standalone: true,
imports: [],
templateUrl: './carousel-tab.component.html',
styleUrl: './carousel-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CarouselTabComponent {
@Input({required: true}) id!: TabId;
@ContentChild(TemplateRef, {static: true}) implicitContent!: TemplateRef<any>;
}

View File

@ -1,27 +0,0 @@
<ng-container *transloco="let t;">
<div class="carousel-tabs-wrapper">
<button class="scroll-button left" (click)="scroll('left')" [class.visible]="showLeftArrow">
<i class="fas fa-chevron-left"></i>
</button>
<div class="carousel-tabs-container" #scrollContainer (scroll)="onScroll()">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" (navChange)="onNavChange($event)">
@for (tab of tabComponents; track tab) {
<li [ngbNavItem]="tab.id">
<a ngbNavLink>{{t('tabs.' + tab.id)}}</a>
<ng-template ngbNavContent>
<!-- <ng-container [ngTemplateOutlet]="tab.contentTemplate"></ng-container>-->
<ng-content select="app-carousel-tab[id='{{tab.id}}']"></ng-content>
</ng-template>
</li>
}
</ul>
</div>
<button class="scroll-button right" (click)="scroll('right')" [class.visible]="showRightArrow">
<i class="fas fa-chevron-right"></i>
</button>
</div>
<div [ngbNavOutlet]="nav" style="min-height: 300px"></div>
</ng-container>

View File

@ -1,45 +0,0 @@
.carousel-tabs-wrapper {
position: relative;
display: flex;
align-items: center;
}
.carousel-tabs-container {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
scrollbar-width: none;
flex-grow: 1;
}
.carousel-tabs-container::-webkit-scrollbar {
display: none;
}
.nav-tabs {
flex-wrap: nowrap;
}
.scroll-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: rgba(255, 255, 255, 0.7);
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 1;
}
.scroll-button.left {
left: 0;
}
.scroll-button.right {
right: 0;
}
.scroll-button.visible {
opacity: 1;
}

View File

@ -1,127 +0,0 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChild,
ContentChildren, ElementRef, EventEmitter, HostListener,
inject, Input, OnInit, Output, QueryList,
TemplateRef, ViewChild
} from '@angular/core';
import {
NgbNav,
NgbNavChangeEvent,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet
} from "@ng-bootstrap/ng-bootstrap";
import {CarouselTabComponent} from "../carousel-tab/carousel-tab.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {NgTemplateOutlet} from "@angular/common";
/**
* Any Tabs that use this Carousel should use these
*/
export enum TabId {
Related = 'related-tab',
Reviews = 'review-tab', // Only applicable for books
Details = 'details-tab',
Chapters = 'chapters-tab',
}
@Component({
selector: 'app-carousel-tabs',
standalone: true,
imports: [
NgbNav,
TranslocoDirective,
NgbNavItem,
NgbNavLink,
NgTemplateOutlet,
NgbNavOutlet,
NgbNavContent
],
templateUrl: './carousel-tabs.component.html',
styleUrl: './carousel-tabs.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CarouselTabsComponent implements OnInit, AfterViewInit {
private readonly cdRef = inject(ChangeDetectorRef);
@ContentChildren(CarouselTabComponent) tabComponents!: QueryList<CarouselTabComponent>;
@Input({required: true}) activeTabId!: TabId;
@Output() activeTabIdChange = new EventEmitter<TabId>();
@Output() navChange = new EventEmitter<NgbNavChangeEvent>();
@ViewChild('scrollContainer') scrollContainer: ElementRef | undefined;
tabs: { id: TabId; contentTemplate: any }[] = [];
showLeftArrow = false;
showRightArrow = false;
ngOnInit() {
this.checkOverflow();
}
ngAfterViewInit() {
this.initializeTabs();
this.scrollToActiveTab();
this.checkOverflow();
}
initializeTabs() {
this.tabs = this.tabComponents.map(tabComponent => ({
id: tabComponent.id,
contentTemplate: tabComponent.implicitContent
}));
this.cdRef.markForCheck();
}
@HostListener('window:resize')
onResize() {
this.checkOverflow();
}
onNavChange(event: NgbNavChangeEvent) {
this.activeTabIdChange.emit(event.nextId);
this.navChange.emit(event);
this.scrollToActiveTab();
}
onScroll() {
this.checkOverflow();
}
scrollToActiveTab() {
setTimeout(() => {
const activeTab = this.scrollContainer?.nativeElement.querySelector('.active');
if (activeTab) {
activeTab.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
this.checkOverflow();
});
}
checkOverflow() {
const element = this.scrollContainer?.nativeElement;
if (!element) return;
this.showLeftArrow = element.scrollLeft > 0;
this.showRightArrow = element.scrollLeft < element.scrollWidth - element.clientWidth;
this.cdRef.markForCheck();
}
scroll(direction: 'left' | 'right') {
const element = this.scrollContainer?.nativeElement;
if (!element) return;
const scrollAmount = element.clientWidth / 2;
if (direction === 'left') {
element.scrollBy({ left: -scrollAmount, behavior: 'smooth' });
} else {
element.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}
}

View File

@ -4,11 +4,16 @@
@if (chapter && series && libraryType !== null) { @if (chapter && series && libraryType !== null) {
<div class="row mb-0 mb-xl-3 info-container"> <div class="row mb-0 mb-xl-3 info-container">
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3 position-relative"> <div [ngClass]="mobileSeriesImgBackground === 'true' ? 'mobile-bg' : ''" class="image-container col-5 col-sm-12 col-md-12 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3 position-relative">
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="coverImage"></app-image> @if(mobileSeriesImgBackground === 'true') {
@if (chapter.pagesRead < chapter.pages && chapter.pagesRead > 0) { <app-image [styles]="{'background': 'none'}" [imageUrl]="coverImage"></app-image>
<div class="progress-banner" ngbTooltip="{{(chapter.pagesRead / chapter.pages) * 100 | number:'1.0-1'}}%"> } @else {
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="coverImage"></app-image>
}
<!-- TODO: For when continue on chapter/issue is hooked up -->
@if (chapter.pagesRead < chapter.pages && chapter.pagesRead > 0) {
<div class="progress-banner series" ngbTooltip="{{(chapter.pagesRead / chapter.pages) * 100 | number:'1.0-1'}}%">
<ngb-progressbar type="primary" [value]="chapter.pagesRead" [max]="chapter.pages" [showValue]="true"></ngb-progressbar> <ngb-progressbar type="primary" [value]="chapter.pagesRead" [max]="chapter.pages" [showValue]="true"></ngb-progressbar>
</div> </div>
} }
@ -25,25 +30,22 @@
</div> </div>
</div> </div>
<div class="col-xl-10 col-lg-7 col-md-7 col-xs-8 col-sm-6"> <div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12">
<h4 class="title mb-2"> <h4 class="title mb-2">
<a routerLink="/library/{{series.libraryId}}/series/{{series.id}}" class="dark-exempt btn-icon">{{series.name}}</a> <a routerLink="/library/{{series.libraryId}}/series/{{series.id}}" class="dark-exempt btn-icon">{{series.name}}</a>
</h4> </h4>
<div class="subtitle mt-2 mb-2"> <div class="subtitle mt-2 mb-2">
<span> <span class="me-2">
<app-entity-title [libraryType]="libraryType!" [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title> <app-entity-title [libraryType]="libraryType" [entity]="chapter" [prioritizeTitleName]="true" [includeChapter]="true"></app-entity-title>
</span> </span>
@if (chapter.titleName) {
<span class="ms-2 me-2"></span>
<span>{{chapter.titleName}}</span>
}
</div> </div>
<app-metadata-detail-row [entity]="chapter" <app-metadata-detail-row [entity]="chapter"
[ageRating]="chapter.ageRating" [ageRating]="chapter.ageRating"
[hasReadingProgress]="chapter.pagesRead > 0" [hasReadingProgress]="chapter.pagesRead > 0"
[readingTimeEntity]="chapter" [readingTimeEntity]="chapter"
[libraryType]="libraryType"> [libraryType]="libraryType"
[mangaFormat]="series.format">
</app-metadata-detail-row> </app-metadata-detail-row>
@ -92,6 +94,12 @@
</div> </div>
} }
<div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-secondary-outline btn"></app-card-actionables>
</div>
</div>
<div class="col-auto ms-2 d-none d-md-block"> <div class="col-auto ms-2 d-none d-md-block">
<app-download-button [download$]="download$" [entity]="chapter" entityType="chapter"></app-download-button> <app-download-button [download$]="download$" [entity]="chapter" entityType="chapter"></app-download-button>
</div> </div>
@ -108,7 +116,7 @@
<div class="col-6"> <div class="col-6">
<span class="fw-bold">{{t('writers-title')}}</span> <span class="fw-bold">{{t('writers-title')}}</span>
<div> <div>
<app-badge-expander [items]="chapter.writers"> <app-badge-expander [items]="chapter.writers" [allowToggle]="false" (toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last"> <ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a> <a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
</ng-template> </ng-template>
@ -116,11 +124,50 @@
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6">
<span class="fw-bold">{{t('cover-artists-title')}}</span> @if (chapter.releaseDate !== '0001-01-01T00:00:00' && (libraryType === LibraryType.ComicVine || libraryType === LibraryType.Comic)) {
<span class="fw-bold">{{t('release-date-title')}}</span>
<div>
<a class="dark-exempt btn-icon" href="javascript:void(0);">{{chapter.releaseDate | date: 'shortDate' | defaultDate:'—'}}</a>
</div>
} @else {
<span class="fw-bold">{{t('cover-artists-title')}}</span>
<div>
<app-badge-expander [items]="chapter.coverArtists" [allowToggle]="false" (toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
</ng-template>
</app-badge-expander>
</div>
}
</div>
</div>
</div>
<div class="mt-3 mb-2 upper-details">
<div class="row g-0">
<div class="col-6 pe-5">
<span class="fw-bold">{{t('genres-title')}}</span>
<div> <div>
<app-badge-expander [items]="chapter.coverArtists"> <app-badge-expander [items]="chapter.genres"
[itemsTillExpander]="3"
[allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last"> <ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a> <a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Genres, item.id)">{{item.title}}</a>
</ng-template>
</app-badge-expander>
</div>
</div>
<div class="col-6">
<span class="fw-bold">{{t('tags-title')}}</span>
<div>
<app-badge-expander [items]="chapter.tags"
[itemsTillExpander]="3"
[allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Tags, item.id)">{{item.title}}</a>
</ng-template> </ng-template>
</app-badge-expander> </app-badge-expander>
</div> </div>
@ -131,16 +178,8 @@
</div> </div>
</div> </div>
<!-- <app-carousel-tabs [(activeTabId)]="activeTabId">--> <div class="carousel-tabs-container mb-2">
<!-- <app-carousel-tab [id]="TabId.Details">--> <ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs" (navChange)="onNavChange($event)">
<!-- @defer (when activeTabId === TabId.Details; prefetch on idle) {-->
<!-- <app-details-tab [metadata]="chapter" [genres]="chapter.genres" [tags]="chapter.tags"></app-details-tab>-->
<!-- }-->
<!-- </app-carousel-tab>-->
<!-- </app-carousel-tabs>-->
<div class="carousel-tabs-container">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" (navChange)="onNavChange($event)">
@if (showDetailsTab) { @if (showDetailsTab) {
<li [ngbNavItem]="TabID.Details"> <li [ngbNavItem]="TabID.Details">

View File

@ -9,10 +9,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component"; import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component"; import {TagBadgeComponent} from "../shared/tag-badge/tag-badge.component";
import {AsyncPipe, DecimalPipe, DOCUMENT, NgStyle} from "@angular/common"; import {AsyncPipe, DecimalPipe, DOCUMENT, NgStyle, NgClass, DatePipe} from "@angular/common";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {ExternalListItemComponent} from "../cards/external-list-item/external-list-item.component";
import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component"; import {ExternalSeriesCardComponent} from "../cards/external-series-card/external-series-card.component";
import {ImageComponent} from "../shared/image/image.component"; import {ImageComponent} from "../shared/image/image.component";
import {LoadingComponent} from "../shared/loading/loading.component"; import {LoadingComponent} from "../shared/loading/loading.component";
@ -73,9 +72,16 @@ import {
} from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component"; } from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component";
import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component"; import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component";
import {hasAnyCast} from "../_models/common/i-has-cast"; import {hasAnyCast} from "../_models/common/i-has-cast";
import {CarouselTabComponent} from "../carousel/_components/carousel-tab/carousel-tab.component";
import {CarouselTabsComponent, TabId} from "../carousel/_components/carousel-tabs/carousel-tabs.component";
import {Breakpoint, UtilityService} from "../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
import {CoverUpdateEvent} from "../_models/events/cover-update-event";
import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Device} from "../_models/device/device";
import {ActionService} from "../_services/action.service";
import {PublicationStatusPipe} from "../_pipes/publication-status.pipe";
import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {MangaFormatPipe} from "../_pipes/manga-format.pipe";
enum TabID { enum TabID {
Related = 'related-tab', Related = 'related-tab',
@ -86,53 +92,55 @@ enum TabID {
@Component({ @Component({
selector: 'app-chapter-detail', selector: 'app-chapter-detail',
standalone: true, standalone: true,
imports: [ imports: [
BulkOperationsComponent, BulkOperationsComponent,
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
CarouselReelComponent, CarouselReelComponent,
DecimalPipe, DecimalPipe,
ExternalListItemComponent, ExternalSeriesCardComponent,
ExternalSeriesCardComponent, ImageComponent,
ImageComponent, LoadingComponent,
LoadingComponent, NgbDropdown,
NgbDropdown, NgbDropdownItem,
NgbDropdownItem, NgbDropdownMenu,
NgbDropdownMenu, NgbDropdownToggle,
NgbDropdownToggle, NgbNav,
NgbNav, NgbNavContent,
NgbNavContent, NgbNavLink,
NgbNavLink, NgbProgressbar,
NgbProgressbar, NgbTooltip,
NgbTooltip, PersonBadgeComponent,
PersonBadgeComponent, ReviewCardComponent,
ReviewCardComponent, SeriesCardComponent,
SeriesCardComponent, TagBadgeComponent,
TagBadgeComponent, VirtualScrollerModule,
VirtualScrollerModule, NgStyle,
NgStyle, NgClass,
AgeRatingPipe, AgeRatingPipe,
TimeDurationPipe, TimeDurationPipe,
ExternalRatingComponent, ExternalRatingComponent,
TranslocoDirective, TranslocoDirective,
ReadMoreComponent, ReadMoreComponent,
NgbNavItem, NgbNavItem,
NgbNavOutlet, NgbNavOutlet,
DetailsTabComponent, DetailsTabComponent,
RouterLink, RouterLink,
EntityTitleComponent, EntityTitleComponent,
ReadTimePipe, ReadTimePipe,
DefaultValuePipe, DefaultValuePipe,
CardItemComponent, CardItemComponent,
RelatedTabComponent, RelatedTabComponent,
AgeRatingImageComponent, AgeRatingImageComponent,
CompactNumberPipe, CompactNumberPipe,
BadgeExpanderComponent, BadgeExpanderComponent,
MetadataDetailRowComponent, MetadataDetailRowComponent,
DownloadButtonComponent, DownloadButtonComponent,
CarouselTabComponent, PublicationStatusPipe,
CarouselTabsComponent DatePipe,
], DefaultDatePipe,
MangaFormatPipe
],
templateUrl: './chapter-detail.component.html', templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss', styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@ -158,11 +166,14 @@ export class ChapterDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly readingListService = inject(ReadingListService); private readonly readingListService = inject(ReadingListService);
protected readonly utilityService = inject(UtilityService); protected readonly utilityService = inject(UtilityService);
private readonly messageHub = inject(MessageHubService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly actionService = inject(ActionService);
protected readonly AgeRating = AgeRating; protected readonly AgeRating = AgeRating;
protected readonly TabID = TabID; protected readonly TabID = TabID;
protected readonly FilterField = FilterField; protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -184,7 +195,8 @@ export class ChapterDetailComponent implements OnInit {
downloadInProgress: boolean = false; downloadInProgress: boolean = false;
readingLists: ReadingList[] = []; readingLists: ReadingList[] = [];
showDetailsTab: boolean = true; showDetailsTab: boolean = true;
mobileSeriesImgBackground: string | undefined;
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
get ScrollingBlockHeight() { get ScrollingBlockHeight() {
@ -208,13 +220,28 @@ export class ChapterDetailComponent implements OnInit {
return; return;
} }
this.mobileSeriesImgBackground = getComputedStyle(document.documentElement)
.getPropertyValue('--mobile-series-img-background').trim();
this.seriesId = parseInt(seriesId, 10); this.seriesId = parseInt(seriesId, 10);
this.chapterId = parseInt(chapterId, 10); this.chapterId = parseInt(chapterId, 10);
this.libraryId = parseInt(libraryId, 10); this.libraryId = parseInt(libraryId, 10);
this.coverImage = this.imageService.getChapterCoverImage(this.chapterId); this.coverImage = this.imageService.getChapterCoverImage(this.chapterId);
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
if (event.event === EVENTS.CoverUpdate) {
const coverUpdateEvent = event.payload as CoverUpdateEvent;
if (coverUpdateEvent.entityType === 'chapter' && coverUpdateEvent.id === this.chapterId) {
this.themeService.refreshColorScape('chapter', coverUpdateEvent.id).subscribe();
}
} else if (event.event === EVENTS.ChapterRemoved) {
const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.chapterId !== this.chapterId) return;
// This series has been deleted from disk, redirect to series
this.router.navigate(['library', this.libraryId, 'series', this.seriesId]);
}
});
forkJoin({ forkJoin({
series: this.seriesService.getSeries(this.seriesId), series: this.seriesService.getSeries(this.seriesId),
@ -303,7 +330,7 @@ export class ChapterDetailComponent implements OnInit {
updateUrl(activeTab: TabID) { updateUrl(activeTab: TabID) {
const newUrl = `${this.router.url.split('#')[0]}#${activeTab}`; const newUrl = `${this.router.url.split('#')[0]}#${activeTab}`;
//this.router.navigateByUrl(newUrl, { onSameUrlNavigation: 'ignore' }); window.history.replaceState({}, '', newUrl);
} }
openPerson(field: FilterField, value: number) { openPerson(field: FilterField, value: number) {
@ -311,12 +338,61 @@ export class ChapterDetailComponent implements OnInit {
} }
downloadChapter() { downloadChapter() {
if (this.downloadInProgress) return;
this.downloadService.download('chapter', this.chapter!, (d) => { this.downloadService.download('chapter', this.chapter!, (d) => {
this.downloadInProgress = !!d; this.downloadInProgress = !!d;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
protected readonly TabId = TabId; openFilter(field: FilterField, value: string | number) {
protected readonly Breakpoint = Breakpoint; this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
switchTabsToDetail() {
this.activeTabId = TabID.Details;
this.cdRef.markForCheck();
}
performAction(action: ActionItem<Chapter>) {
if (typeof action.callback === 'function') {
action.callback(action, this.chapter!);
}
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) {
case(Action.MarkAsRead):
this.actionService.markChapterAsRead(this.libraryId, this.seriesId, chapter, () => {
this.loadData();
});
break;
case(Action.MarkAsUnread):
this.actionService.markChapterAsUnread(this.libraryId, this.seriesId, chapter, () => {
this.loadData();
});
break;
case(Action.Edit):
this.openEditModal();
break;
case(Action.AddToReadingList):
this.actionService.addChapterToReadingList(chapter, this.seriesId, () => {/* No Operation */ });
break;
case(Action.IncognitoRead):
this.readerService.readChapter(this.libraryId, this.seriesId, chapter, true);
break;
case (Action.SendTo):
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([chapter.id], device);
break;
case Action.Download:
this.downloadChapter();
break;
case Action.Delete:
this.router.navigate(['library', this.libraryId, 'series', this.seriesId]);
break;
}
}
protected readonly LibraryType = LibraryType;
} }

View File

@ -19,7 +19,7 @@
&& series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) { && series.length !== collectionTag.totalSourceCount && collectionTag.totalSourceCount > 0) {
<div class="under-image"> <div class="under-image">
<app-image [imageUrl]="collectionTag.source | providerImage" <app-image [imageUrl]="collectionTag.source | providerImage"
width="16px" height="16px" [styles]="{'vertical-align': 'text-top'}" width="16px" height="16px"
[ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image> [ngbTooltip]="collectionTag.source | providerName" tabindex="0"></app-image>
<span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span> <span class="ms-2 me-2">{{t('sync-progress', {title: series.length + ' / ' + collectionTag.totalSourceCount})}}</span>
<i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'short' | defaultDate })"></i> <i class="fa-solid fa-question-circle" aria-hidden="true" [ngbTooltip]="t('last-sync', {date: collectionTag.lastSyncUtc | date: 'short' | defaultDate })"></i>

View File

@ -66,9 +66,20 @@
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick(StreamId.RecentlyUpdatedSeries)"> <app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick(StreamId.RecentlyUpdatedSeries)">
<ng-template #carouselItem let-item> <ng-template #carouselItem let-item>
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)" <app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item> [suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"
[showReadButton]="true" (readClicked)="handleRecentlyAddedChapterRead(item)">
</app-card-item>
</ng-template> </ng-template>
</app-carousel-reel> </app-carousel-reel>
<ng-template #itemOverlay let-item="item">
<span (click)="handleRecentlyAddedChapterClick(item)">
<div>
<i class="fa-solid fa-book" aria-hidden="true"></i>
</div>
</span>
</ng-template>
} }
</ng-template> </ng-template>

View File

@ -34,6 +34,7 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {ServerService} from "../../_services/server.service"; import {ServerService} from "../../_services/server.service";
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component"; import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
import {ReaderService} from "../../_services/reader.service";
enum StreamId { enum StreamId {
OnDeck, OnDeck,
@ -69,7 +70,7 @@ export class DashboardComponent implements OnInit {
private readonly dashboardService = inject(DashboardService); private readonly dashboardService = inject(DashboardService);
private readonly scrobblingService = inject(ScrobblingService); private readonly scrobblingService = inject(ScrobblingService);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
private readonly serverService = inject(ServerService); private readonly readerService = inject(ReaderService);
libraries$: Observable<Library[]> = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef)) libraries$: Observable<Library[]> = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef))
isLoadingDashboard = true; isLoadingDashboard = true;
@ -203,6 +204,13 @@ export class DashboardComponent implements OnInit {
await this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); await this.router.navigate(['library', item.libraryId, 'series', item.seriesId]);
} }
async handleRecentlyAddedChapterRead(item: RecentlyAddedItem) {
// Get Continue Reading point and open directly
this.readerService.getCurrentChapter(item.seriesId).subscribe(chapter => {
this.readerService.readChapter(item.libraryId, item.seriesId, chapter, false);
});
}
async handleFilterSectionClick(stream: DashboardStream) { async handleFilterSectionClick(stream: DashboardStream) {
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded); await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
} }

View File

@ -5,10 +5,9 @@
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-alt')}}</a> <a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-alt')}}</a>
@if (navService.sideNavVisibility$ | async) { @if (navService.sideNavVisibility$ | async) {
<a class="side-nav-toggle" (click)="hideSideNav()"><i class="fas fa-bars"></i></a> <a class="side-nav-toggle" (click)="toggleSideNav($event)"><i class="fas fa-bars" aria-hidden="true"></i></a>
} }
<a class="navbar-brand dark-exempt" routerLink="/home" routerLinkActive="active"> <a class="navbar-brand dark-exempt" routerLink="/home" routerLinkActive="active">
<app-image width="28px" height="28px" imageUrl="assets/images/logo-32.png" classes="logo" /> <app-image width="28px" height="28px" imageUrl="assets/images/logo-32.png" classes="logo" />
<span class="d-none d-md-inline logo"> Kavita</span> <span class="d-none d-md-inline logo"> Kavita</span>

View File

@ -296,7 +296,8 @@ export class NavHeaderComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
hideSideNav() { toggleSideNav(event: any) {
event.stopPropagation();
this.navService.toggleSideNav(); this.navService.toggleSideNav();
} }

View File

@ -7,7 +7,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<a routerLink="/settings" [fragment]="SettingsTabId.Preferences" [title]="t('settings')">{{t('settings')}}</a> <a routerLink="/settings" [fragment]="SettingsTabId.Preferences" (click)="closeIfOnSettings()" [title]="t('settings')">{{t('settings')}}</a>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<a routerLink="/all-filters/">{{t('all-filters')}}</a> <a routerLink="/all-filters/">{{t('all-filters')}}</a>

View File

@ -1,7 +1,7 @@
import {Component, inject, Input} from '@angular/core'; import {Component, inject, Input} from '@angular/core';
import {WikiLink} from "../../../_models/wiki"; import {WikiLink} from "../../../_models/wiki";
import {NgbActiveModal, NgbDropdownItem} from "@ng-bootstrap/ng-bootstrap"; import {NgbActiveModal, NgbDropdownItem} from "@ng-bootstrap/ng-bootstrap";
import {RouterLink} from "@angular/router"; import {ActivatedRoute, Router, RouterLink, UrlSegment} from "@angular/router";
import {FilterPipe} from "../../../_pipes/filter.pipe"; import {FilterPipe} from "../../../_pipes/filter.pipe";
import {ReactiveFormsModule} from "@angular/forms"; import {ReactiveFormsModule} from "@angular/forms";
import {Select2Module} from "ng-select2-component"; import {Select2Module} from "ng-select2-component";
@ -27,8 +27,10 @@ export class NavLinkModalComponent {
@Input({required: true}) logoutFn!: () => void; @Input({required: true}) logoutFn!: () => void;
private readonly modal = inject(NgbActiveModal); private readonly modal = inject(NgbActiveModal);
private readonly router = inject(Router);
protected readonly WikiLink = WikiLink; protected readonly WikiLink = WikiLink;
protected readonly SettingsTabId = SettingsTabId;
close() { close() {
this.modal.close(); this.modal.close();
@ -38,5 +40,14 @@ export class NavLinkModalComponent {
this.logoutFn(); this.logoutFn();
} }
protected readonly SettingsTabId = SettingsTabId; closeIfOnSettings() {
setTimeout(() => {
const currentUrl = this.router.url;
if (currentUrl.startsWith('/settings')) {
this.close();
}
}, 10);
}
} }

View File

@ -1,157 +1,162 @@
<ng-container *transloco="let t; read: 'pdf-reader'"> <ng-container *transloco="let t; read: 'pdf-reader'">
<div class="{{theme}}" *ngIf="accountService.currentUser$ | async as user" #container> @if (accountService.currentUser$ | async; as user) {
<div class="{{theme}}" #container>
<ng-container *ngIf="isLoading"> @if (isLoading) {
<div class="loading mx-auto" style="min-width: 200px; width: 600px;"> <div class="loading mx-auto" style="min-width: 200px; width: 600px;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{t('loading-message')}} {{t('loading-message')}}
</div>
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': loadPercent + '%'}" [attr.aria-valuenow]="loadPercent" aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
</div> <div class="progress-container row g-0 align-items-center">
</ng-container> <div class="progress" style="height: 5px;">
<ngx-extended-pdf-viewer <div class="progress-bar" role="progressbar" [ngStyle]="{'width': loadPercent + '%'}" [attr.aria-valuenow]="loadPercent" aria-valuemin="0" aria-valuemax="100"></div>
#pdfViewer </div>
[src]="readerService.downloadPdf(this.chapterId)" </div>
[authorization]="'Bearer ' + user.token" }
height="100vh"
[(page)]="currentPage"
[textLayer]="true"
[useBrowserLocale]="true"
[showHandToolButton]="true"
[showOpenFileButton]="false"
[showPrintButton]="false"
[showRotateButton]="false"
[showDownloadButton]="false"
[showPropertiesButton]="false"
[(zoom)]="zoomSetting"
[showSecondaryToolbarButton]="true"
[showBorders]="true"
[theme]="theme"
[backgroundColor]="backgroundColor"
[customToolbar]="multiToolbar"
[language]="user.preferences.locale"
[(scrollMode)]="scrollMode" <ngx-extended-pdf-viewer
[pageViewMode]="pageLayoutMode" #pdfViewer
[spread]="spreadMode" [src]="readerService.downloadPdf(this.chapterId)"
[authorization]="'Bearer ' + user.token"
height="100vh"
[(page)]="currentPage"
[textLayer]="true"
[useBrowserLocale]="true"
[showHandToolButton]="true"
[showOpenFileButton]="false"
[showPrintButton]="false"
[showRotateButton]="false"
[showDownloadButton]="false"
[showPropertiesButton]="false"
[(zoom)]="zoomSetting"
[showSecondaryToolbarButton]="true"
[showBorders]="true"
[theme]="theme"
[backgroundColor]="backgroundColor"
[customToolbar]="multiToolbar"
[language]="user.preferences.locale"
(pageChange)="saveProgress()" [(scrollMode)]="scrollMode"
(pdfLoadingStarts)="updateLoading(true)" [pageViewMode]="pageLayoutMode"
(pdfLoaded)="updateLoading(false)" [spread]="spreadMode"
(progress)="updateLoadProgress($event)"
(zoomChange)="calcScrollbarNeeded()"
(handToolChange)="updateHandTool($event)"
>
</ngx-extended-pdf-viewer> (pageChange)="saveProgress()"
(pdfLoadingStarts)="updateLoading(true)"
(pdfLoaded)="updateLoading(false)"
(progress)="updateLoadProgress($event)"
(zoomChange)="calcScrollbarNeeded()"
(handToolChange)="updateHandTool($event)"
>
@if (scrollMode === ScrollModeType.page && !isLoading) { </ngx-extended-pdf-viewer>
<div class="left" (click)="prevPage()"></div>
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
}
<ng-template #multiToolbar> @if (scrollMode === ScrollModeType.page && !isLoading) {
<div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}"> <div class="left" (click)="prevPage()"></div>
<div id="toolbarViewerLeft"> <div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
<pdf-toggle-sidebar></pdf-toggle-sidebar> }
<pdf-find-button [textLayer]='true'></pdf-find-button>
<pdf-paging-area></pdf-paging-area>
@if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) { <ng-template #multiToolbar>
<button class="btn-icon mt-0 mb-0 pt-1 pb-0 toolbarButton" [ngbTooltip]="bookTitle"> <div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}">
<i class="toolbar-icon fa-solid fa-info" [ngStyle]="{color: fontColor}" aria-hidden="true"></i> <div id="toolbarViewerLeft">
<span class="visually-hidden">{{bookTitle}}</span> <pdf-toggle-sidebar></pdf-toggle-sidebar>
<pdf-find-button [textLayer]='true'></pdf-find-button>
<pdf-paging-area></pdf-paging-area>
@if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) {
<button class="btn-icon mt-0 mb-0 pt-1 pb-0 toolbarButton" [ngbTooltip]="bookTitle">
<i class="toolbar-icon fa-solid fa-info" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
<span class="visually-hidden">{{bookTitle}}</span>
</button>
}
@if (incognitoMode) {
<button [ngbTooltip]="t('toggle-incognito')" (click)="turnOffIncognito()" class="btn-icon mt-0 mb-0 pt-1 pb-0 toolbarButton">
<i class="toolbar-icon fa fa-glasses" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('incognito-mode')}}</span>
</button>
}
<button class="btn-icon col-2 col-xs-1 mt-0 mb-0 pt-1 pb-0 toolbarButton" (click)="closeReader()" [ngbTooltip]="t('close-reader-alt')">
<i class="toolbar-icon fa fa-times-circle" aria-hidden="true" [ngStyle]="{color: fontColor}"></i>
<span class="visually-hidden">{{t('close-reader-alt')}}</span>
</button> </button>
} </div>
<button *ngIf="incognitoMode" [ngbTooltip]="t('toggle-incognito')" (click)="turnOffIncognito()" class="btn-icon mt-0 mb-0 pt-1 pb-0 toolbarButton"> <pdf-zoom-toolbar ></pdf-zoom-toolbar>
<i class="toolbar-icon fa fa-glasses" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
<span class="visually-hidden">{{t('incognito-mode')}}</span>
</button> <div id="toolbarViewerRight">
<pdf-hand-tool></pdf-hand-tool>
<pdf-select-tool></pdf-select-tool>
<pdf-presentation-mode></pdf-presentation-mode>
<!-- The book mode is messy, not ready for prime time -->
<!-- @if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) {-->
<!-- <button (click)="toggleBookPageMode()" class="btn-icon toolbarButton" [ngbTooltip]="pageLayoutMode | pdfLayoutMode" [disabled]="scrollMode === ScrollModeType.page">-->
<!-- <i class="toolbar-icon fa-solid {{this.pageLayoutMode !== 'book' ? 'fa-book' : 'fa-book-open'}}" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>-->
<!-- <span class="visually-hidden">{{this.pageLayoutMode | pdfLayoutMode}}</span>-->
<!-- </button>-->
<!-- }-->
<!-- scroll mode should be disabled when book mode is used -->
<button (click)="toggleScrollMode()" class="btn-icon toolbarButton" [ngbTooltip]="scrollMode | pdfScrollModeType" [disabled]="this.pageLayoutMode === 'book'">
@switch (scrollMode) {
@case (ScrollModeType.vertical) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"></path></svg>
}
@case (ScrollModeType.page) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,7V9H12V17H14V7H10Z"></path></svg>
}
@case (ScrollModeType.horizontal) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px"> <path fill="currentColor" d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"></path> </svg>
}
@case (ScrollModeType.wrapped) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"></path></svg>
}
}
<span class="visually-hidden">{{scrollMode | pdfScrollModeType}}</span>
</button>
<button (click)="toggleSpreadMode()" class="btn-icon toolbarButton" [ngbTooltip]="spreadMode | pdfSpreadType" [disabled]="this.pageLayoutMode === 'book'">
@switch (spreadMode) {
@case ('off') {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M6 3c-1 0-1.5.5-1.5 1.5v7c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5v-7c0-1-.5-1.5-1.5-1.5z"></path></svg>
}
@case ('odd') {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M10.56 3.5C9.56 3.5 9 4 9 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.93 1.2c.8 0 1.4.2 1.8.64.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.44-.2.3-.6.6-1 .93l-.6.4c-.4.3-.6.4-.7.55-.1.1-.2.2-.3.4h3.2v1.27h-5c0-.5.1-1 .3-1.43.2-.49.7-1 1.5-1.54.7-.5 1.1-.8 1.3-1.02.3-.3.4-.7.4-1.05 0-.3-.1-.6-.3-.77-.2-.2-.4-.3-.7-.3-.4 0-.7.2-.9.5-.1.2-.1.5-.2.9h-1.4c0-.6.2-1.1.3-1.5.4-.7 1.1-1.1 2-1.1zM1.54 3.5C.54 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.54 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.8 1.125H4.5V12H3V6.9H1.3v-1c.5 0 .8 0 .97-.03.33-.07.53-.17.73-.37.1-.2.2-.3.25-.5.05-.2.05-.3.05-.3z"></path></svg>
}
@case ('even') {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px"><path fill="currentColor" d="M1.5 3.5C.5 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm2 1.2c.8 0 1.4.2 1.8.6.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.4-.2.3-.5.7-1 1l-.6.4c-.4.3-.6.4-.75.56-.15.14-.25.24-.35.44H6v1.3H1c0-.6.1-1.1.3-1.5.3-.6.7-1 1.5-1.6.7-.4 1.1-.8 1.28-1 .32-.3.42-.6.42-1 0-.3-.1-.6-.23-.8-.17-.2-.37-.3-.77-.3s-.7.1-.9.5c-.04.2-.1.5-.1.9H1.1c0-.6.1-1.1.3-1.5.4-.7 1.1-1.1 2.1-1.1zM10.54 3.54C9.5 3.54 9 4 9 5v6.5c0 1 .5 1.5 1.54 1.5h4c.96 0 1.46-.5 1.46-1.5V5c0-1-.5-1.46-1.5-1.46zm1.9.95c.7 0 1.3.2 1.7.5.4.4.6.8.6 1.4 0 .4-.1.8-.4 1.1-.2.2-.3.3-.5.4.1 0 .3.1.6.3.4.3.5.8.5 1.4 0 .6-.2 1.2-.6 1.6-.4.5-1.1.7-1.9.7-1 0-1.8-.3-2.2-1-.14-.29-.24-.69-.24-1.29h1.4c0 .3 0 .5.1.7.2.4.5.5 1 .5.3 0 .5-.1.7-.3.2-.2.3-.5.3-.8 0-.5-.2-.8-.6-.95-.2-.05-.5-.15-1-.15v-1c.5 0 .8-.1 1-.14.3-.1.5-.4.5-.9 0-.3-.1-.5-.2-.7-.2-.2-.4-.3-.7-.3-.3 0-.6.1-.75.3-.2.2-.2.5-.2.86h-1.34c0-.4.1-.7.19-1.1 0-.12.2-.32.4-.62.2-.2.4-.3.7-.4.3-.1.6-.1 1-.1z"></path></svg>
}
}
<span class="visually-hidden">{{spreadMode | pdfSpreadType}}</span>
</button>
<!-- This is pretty experimental, so it might not work perfectly -->
<button (click)="toggleTheme()" class="btn-icon mt-0 mb-0 pt-1 pb-0 toolbarButton toolbar-btn-fix">
<i class="toolbar-icon fa-solid {{this.theme === 'light' ? 'fa-sun' : 'fa-moon'}}" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
<span class="visually-hidden">{{this.theme === 'light' ? t('light-theme-alt') : t('dark-theme-alt')}}</span>
</button>
<div class="verticalToolbarSeparator hiddenSmallView"></div>
<pdf-toggle-secondary-toolbar></pdf-toggle-secondary-toolbar>
</div>
<button class="btn-icon col-2 col-xs-1 mt-0 mb-0 pt-1 pb-0 toolbarButton" (click)="closeReader()" [ngbTooltip]="t('close-reader-alt')">
<i class="toolbar-icon fa fa-times-circle" aria-hidden="true" [ngStyle]="{color: fontColor}"></i>
<span class="visually-hidden">{{t('close-reader-alt')}}</span>
</button>
</div> </div>
<pdf-zoom-toolbar ></pdf-zoom-toolbar> </ng-template>
</div>
}
<div id="toolbarViewerRight">
<pdf-hand-tool></pdf-hand-tool>
<pdf-select-tool></pdf-select-tool>
<pdf-presentation-mode></pdf-presentation-mode>
<!-- The book mode is messy, not ready for prime time -->
<!-- @if (utilityService.getActiveBreakpoint() > Breakpoint.Mobile) {-->
<!-- <button (click)="toggleBookPageMode()" class="btn-icon toolbarButton" [ngbTooltip]="pageLayoutMode | pdfLayoutMode" [disabled]="scrollMode === ScrollModeType.page">-->
<!-- <i class="toolbar-icon fa-solid {{this.pageLayoutMode !== 'book' ? 'fa-book' : 'fa-book-open'}}" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>-->
<!-- <span class="visually-hidden">{{this.pageLayoutMode | pdfLayoutMode}}</span>-->
<!-- </button>-->
<!-- }-->
<!-- scroll mode should be disabled when book mode is used -->
<button (click)="toggleScrollMode()" class="btn-icon toolbarButton" [ngbTooltip]="scrollMode | pdfScrollModeType" [disabled]="this.pageLayoutMode === 'book'">
@switch (scrollMode) {
@case (ScrollModeType.vertical) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM11 0v.5c0 1-.5 1.5-1.5 1.5h-3C5.5 2 5 1.5 5 .5V0h6zM11 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6z"></path></svg>
}
@case (ScrollModeType.page) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px" viewBox="0 0 24 24"><path fill="currentColor" d="M10,7V9H12V17H14V7H10Z"></path></svg>
}
@case (ScrollModeType.horizontal) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px"> <path fill="currentColor" d="M0 4h1.5c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5H0zM9.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C5 4.5 5.5 4 6.5 4zM16 4h-1.5c-1 0-1.5.5-1.5 1.5v5c0 1 .5 1.5 1.5 1.5H16z"></path> </svg>
}
@case (ScrollModeType.wrapped) {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M5.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5C1 4.5 1.5 4 2.5 4zM7 0v.5C7 1.5 6.5 2 5.5 2h-3C1.5 2 1 1.5 1 .5V0h6zM7 16v-.5c0-1-.5-1.5-1.5-1.5h-3c-1 0-1.5.5-1.5 1.5v.5h6zM13.5 4c1 0 1.5.5 1.5 1.5v5c0 1-.5 1.5-1.5 1.5h-3c-1 0-1.5-.5-1.5-1.5v-5c0-1 .5-1.5 1.5-1.5zM15 0v.5c0 1-.5 1.5-1.5 1.5h-3C9.5 2 9 1.5 9 .5V0h6zM15 16v-.507c0-1-.5-1.5-1.5-1.5h-3C9.5 14 9 14.5 9 15.5v.5h6z"></path></svg>
}
}
<span class="visually-hidden">{{scrollMode | pdfScrollModeType}}</span>
</button>
<button (click)="toggleSpreadMode()" class="btn-icon toolbarButton" [ngbTooltip]="spreadMode | pdfSpreadType" [disabled]="this.pageLayoutMode === 'book'">
@switch (spreadMode) {
@case ('off') {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M6 3c-1 0-1.5.5-1.5 1.5v7c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5v-7c0-1-.5-1.5-1.5-1.5z"></path></svg>
}
@case ('odd') {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px" viewBox="0 0 24 24"><path fill="currentColor" d="M10.56 3.5C9.56 3.5 9 4 9 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.93 1.2c.8 0 1.4.2 1.8.64.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.44-.2.3-.6.6-1 .93l-.6.4c-.4.3-.6.4-.7.55-.1.1-.2.2-.3.4h3.2v1.27h-5c0-.5.1-1 .3-1.43.2-.49.7-1 1.5-1.54.7-.5 1.1-.8 1.3-1.02.3-.3.4-.7.4-1.05 0-.3-.1-.6-.3-.77-.2-.2-.4-.3-.7-.3-.4 0-.7.2-.9.5-.1.2-.1.5-.2.9h-1.4c0-.6.2-1.1.3-1.5.4-.7 1.1-1.1 2-1.1zM1.54 3.5C.54 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.54 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm1.8 1.125H4.5V12H3V6.9H1.3v-1c.5 0 .8 0 .97-.03.33-.07.53-.17.73-.37.1-.2.2-.3.25-.5.05-.2.05-.3.05-.3z"></path></svg>
}
@case ('even') {
<svg aria-hidden="true" [ngStyle]="{color: fontColor}" style="width: 24px; height: 24px; margin-top: 3px"><path fill="currentColor" d="M1.5 3.5C.5 3.5 0 4 0 5v6.5c0 1 .5 1.5 1.5 1.5h4c1 0 1.5-.5 1.5-1.5V5c0-1-.5-1.5-1.5-1.5zm2 1.2c.8 0 1.4.2 1.8.6.5.4.7 1 .7 1.7 0 .5-.2 1-.5 1.4-.2.3-.5.7-1 1l-.6.4c-.4.3-.6.4-.75.56-.15.14-.25.24-.35.44H6v1.3H1c0-.6.1-1.1.3-1.5.3-.6.7-1 1.5-1.6.7-.4 1.1-.8 1.28-1 .32-.3.42-.6.42-1 0-.3-.1-.6-.23-.8-.17-.2-.37-.3-.77-.3s-.7.1-.9.5c-.04.2-.1.5-.1.9H1.1c0-.6.1-1.1.3-1.5.4-.7 1.1-1.1 2.1-1.1zM10.54 3.54C9.5 3.54 9 4 9 5v6.5c0 1 .5 1.5 1.54 1.5h4c.96 0 1.46-.5 1.46-1.5V5c0-1-.5-1.46-1.5-1.46zm1.9.95c.7 0 1.3.2 1.7.5.4.4.6.8.6 1.4 0 .4-.1.8-.4 1.1-.2.2-.3.3-.5.4.1 0 .3.1.6.3.4.3.5.8.5 1.4 0 .6-.2 1.2-.6 1.6-.4.5-1.1.7-1.9.7-1 0-1.8-.3-2.2-1-.14-.29-.24-.69-.24-1.29h1.4c0 .3 0 .5.1.7.2.4.5.5 1 .5.3 0 .5-.1.7-.3.2-.2.3-.5.3-.8 0-.5-.2-.8-.6-.95-.2-.05-.5-.15-1-.15v-1c.5 0 .8-.1 1-.14.3-.1.5-.4.5-.9 0-.3-.1-.5-.2-.7-.2-.2-.4-.3-.7-.3-.3 0-.6.1-.75.3-.2.2-.2.5-.2.86h-1.34c0-.4.1-.7.19-1.1 0-.12.2-.32.4-.62.2-.2.4-.3.7-.4.3-.1.6-.1 1-.1z"></path></svg>
}
}
<span class="visually-hidden">{{spreadMode | pdfSpreadType}}</span>
</button>
<!-- This is pretty experimental, so it might not work perfectly -->
<button (click)="toggleTheme()" class="btn-icon mt-0 mb-0 pt-1 pb-0 toolbarButton toolbar-btn-fix">
<i class="toolbar-icon fa-solid {{this.theme === 'light' ? 'fa-sun' : 'fa-moon'}}" [ngStyle]="{color: fontColor}" aria-hidden="true"></i>
<span class="visually-hidden">{{this.theme === 'light' ? t('light-theme-alt') : t('dark-theme-alt')}}</span>
</button>
<div class="verticalToolbarSeparator hiddenSmallView"></div>
<pdf-toggle-secondary-toolbar></pdf-toggle-secondary-toolbar>
</div>
</div>
</ng-template>
</div>
</ng-container> </ng-container>

View File

@ -13,7 +13,7 @@ import {
import {ActivatedRoute, Router} from '@angular/router'; import {ActivatedRoute, Router} from '@angular/router';
import {NgxExtendedPdfViewerModule, PageViewModeType, ProgressBarEvent, ScrollModeType} from 'ngx-extended-pdf-viewer'; import {NgxExtendedPdfViewerModule, PageViewModeType, ProgressBarEvent, ScrollModeType} from 'ngx-extended-pdf-viewer';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {Observable, take} from 'rxjs'; import {take} from 'rxjs';
import {BookService} from 'src/app/book-reader/_services/book.service'; import {BookService} from 'src/app/book-reader/_services/book.service';
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service'; import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Chapter} from 'src/app/_models/chapter'; import {Chapter} from 'src/app/_models/chapter';
@ -24,7 +24,7 @@ import {CHAPTER_ID_DOESNT_EXIST, ReaderService} from 'src/app/_services/reader.s
import {SeriesService} from 'src/app/_services/series.service'; import {SeriesService} from 'src/app/_services/series.service';
import {ThemeService} from 'src/app/_services/theme.service'; import {ThemeService} from 'src/app/_services/theme.service';
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {AsyncPipe, DOCUMENT, NgIf, NgStyle} from '@angular/common'; import {AsyncPipe, DOCUMENT, NgStyle} from '@angular/common';
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {PdfLayoutMode} from "../../../_models/preferences/pdf-layout-mode"; import {PdfLayoutMode} from "../../../_models/preferences/pdf-layout-mode";
import {PdfScrollMode} from "../../../_models/preferences/pdf-scroll-mode"; import {PdfScrollMode} from "../../../_models/preferences/pdf-scroll-mode";
@ -41,7 +41,7 @@ import {PdfSpreadTypePipe} from "../../_pipe/pdf-spread-mode.pipe";
styleUrls: ['./pdf-reader.component.scss'], styleUrls: ['./pdf-reader.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [NgIf, NgStyle, NgxExtendedPdfViewerModule, NgbTooltip, AsyncPipe, TranslocoDirective, imports: [NgStyle, NgxExtendedPdfViewerModule, NgbTooltip, AsyncPipe, TranslocoDirective,
PdfLayoutModePipe, PdfScrollModeTypePipe, PdfSpreadTypePipe] PdfLayoutModePipe, PdfScrollModeTypePipe, PdfSpreadTypePipe]
}) })
export class PdfReaderComponent implements OnInit, OnDestroy { export class PdfReaderComponent implements OnInit, OnDestroy {

View File

@ -1,17 +1,18 @@
<ng-container *transloco="let t; read: 'shortcuts-modal'"> <ng-container *transloco="let t; read: 'shortcuts-modal'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modal.close()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-6 mb-2" *ngFor="let shortcut of shortcuts"> @for(shortcut of shortcuts; track shortcut.key) {
<span><code>{{shortcut.key}}</code> {{t(shortcut.description)}}</span> <div class="col-md-6 mb-2">
</div> <span><code>{{shortcut.key}}</code> {{t(shortcut.description)}}</span>
</div>
}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button> <button type="button" class="btn btn-primary" (click)="modal.close()">{{t('close')}}</button>
</div> </div>
</ng-container> </ng-container>

View File

@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap'; import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
import {CommonModule} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
export interface KeyboardShortcut { export interface KeyboardShortcut {
@ -17,18 +16,14 @@ export interface KeyboardShortcut {
@Component({ @Component({
selector: 'app-shortcuts-modal', selector: 'app-shortcuts-modal',
standalone: true, standalone: true,
imports: [CommonModule, NgbModalModule, TranslocoDirective], imports: [NgbModalModule, TranslocoDirective],
templateUrl: './shortcuts-modal.component.html', templateUrl: './shortcuts-modal.component.html',
styleUrls: ['./shortcuts-modal.component.scss'], styleUrls: ['./shortcuts-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ShortcutsModalComponent { export class ShortcutsModalComponent {
protected readonly modal = inject(NgbActiveModal);
@Input() shortcuts: Array<KeyboardShortcut> = []; @Input() shortcuts: Array<KeyboardShortcut> = [];
constructor(public modal: NgbActiveModal) { }
close() {
this.modal.close();
}
} }

View File

@ -5,7 +5,9 @@
@if (readingList?.promoted) { @if (readingList?.promoted) {
<span class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span> <span class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
} }
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.aria-labelledby]="readingList?.title" *ngIf="actions.length > 0"></app-card-actionables> @if (actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.aria-labelledby]="readingList?.title"></app-card-actionables>
}
</h4> </h4>
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h5> <h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h5>
@ -90,50 +92,66 @@
</div> </div>
</div> </div>
</div> </div>
<div class="row g-0 mt-2" *ngIf="readingList.startingYear !== 0">
<h4 class="reading-list-years">
<ng-container *ngIf="readingList.startingMonth > 0">{{(readingList.startingMonth +'/01/2020')| date:'MMM'}}</ng-container>
<ng-container *ngIf="readingList.startingMonth > 0 && readingList.startingYear > 0">, </ng-container>
<ng-container *ngIf="readingList.startingYear > 0">{{readingList.startingYear}}</ng-container>
<ng-container *ngIf="readingList.endingYear > 0">
<ng-container *ngIf="readingList.endingMonth > 0">{{(readingList.endingMonth +'/01/2020') | date:'MMM'}}</ng-container>
<ng-container *ngIf="readingList.endingMonth > 0 && readingList.endingYear > 0">, </ng-container>
<ng-container *ngIf="readingList.endingYear > 0">{{readingList.endingYear}}</ng-container>
</ng-container>
</h4> @if (readingList.startingYear !== 0) {
</div> <div class="row g-0 mt-2">
<h4 class="reading-list-years">
@if (readingList.startingMonth > 0) {
{{(readingList.startingMonth +'/01/2020')| date:'MMM'}}
}
@if (readingList.startingMonth > 0 && readingList.startingYear > 0) {
,
}
@if (readingList.startingYear > 0) {
{{readingList.startingYear}}
}
@if (readingList.endingYear > 0) {
@if (readingList.endingMonth > 0) {
{{(readingList.endingMonth +'/01/2020')| date:'MMM'}}
}
@if (readingList.endingMonth > 0 && readingList.endingYear > 0) {
,
}
@if (readingList.endingYear > 0) {
{{readingList.endingYear}}
}
}
</h4>
</div>
}
<!-- Summary row--> <!-- Summary row-->
<div class="row g-0 mt-2"> <div class="row g-0 mt-2">
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more> <app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
</div> </div>
@if (characters$ | async; as characters) {
@if (characters && characters.length > 0) {
<div class="row mb-2">
<div class="row">
<h5>{{t('characters-title')}}</h5>
<app-badge-expander [items]="characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="goToCharacter(item)">{{item.name}}</a>
</ng-template>
</app-badge-expander>
</div>
</div>
}
}
</div> </div>
</div> </div>
<ng-container *ngIf="characters$ | async as characters">
<div class="row mb-2">
<div class="row" *ngIf="characters && characters.length > 0">
<h5>{{t('characters-title')}}</h5>
<app-badge-expander [items]="characters">
<ng-template #badgeExpanderItem let-item let-position="idx">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="goToCharacter(item)">{{item.name}}</a>
<!-- <app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge>-->
</ng-template>
</app-badge-expander>
</div>
</div>
</ng-container>
<div class="row mb-1 scroll-container" #scrollingBlock> <div class="row mb-1 scroll-container" #scrollingBlock>
<ng-container *ngIf="items.length === 0 && !isLoading; else loading"> @if (items.length === 0 && !isLoading) {
<div class="mx-auto" style="width: 200px;"> <div class="mx-auto" style="width: 200px;">
{{t('no-data')}} {{t('no-data')}}
</div> </div>
</ng-container> } @else if(isLoading) {
<ng-template #loading> <app-loading [loading]="isLoading"></app-loading>
<app-loading *ngIf="isLoading" [loading]="isLoading"></app-loading> }
</ng-template>
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode" <app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false"> [showRemoveButton]="false">

View File

@ -22,7 +22,6 @@
.scroll-container { .scroll-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%;
height: calc((var(--vh) *100) - 173px); height: calc((var(--vh) *100) - 173px);
margin-bottom: 10px; margin-bottom: 10px;

View File

@ -46,7 +46,7 @@ import {Title} from "@angular/platform-browser";
styleUrls: ['./reading-list-detail.component.scss'], styleUrls: ['./reading-list-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [SideNavCompanionBarComponent, NgIf, CardActionablesComponent, ImageComponent, NgbDropdown, imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, NgbDropdown,
NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, ReadMoreComponent, BadgeExpanderComponent,
PersonBadgeComponent, A11yClickDirective, LoadingComponent, DraggableOrderedListComponent, PersonBadgeComponent, A11yClickDirective, LoadingComponent, DraggableOrderedListComponent,
ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective, ReadingListItemComponent, NgClass, AsyncPipe, DecimalPipe, DatePipe, TranslocoDirective,

View File

@ -1,6 +1,6 @@
<ng-container *transloco="let t; read: 'reading-list-item'"> <ng-container *transloco="let t; read: 'reading-list-item'">
<div class="d-flex flex-row g-0 mb-2 reading-list-item"> <div class="d-flex flex-row g-0 mb-2 reading-list-item">
<div class="pe-2"> <div class="d-none d-md-block pe-2">
<app-image width="106px" [styles]="{'max-height': '125px'}" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image> <app-image width="106px" [styles]="{'max-height': '125px'}" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
@if (item.pagesRead === 0 && item.pagesTotal > 0) { @if (item.pagesRead === 0 && item.pagesTotal > 0) {
<div class="not-read-badge" ></div> <div class="not-read-badge" ></div>
@ -16,18 +16,18 @@
<div class="g-0"> <div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}"> <h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{item.title}} {{item.title}}
<div class="float-end"> <div class="actions float-end">
<button class="btn btn-danger" (click)="remove.emit(item)"> <button class="btn btn-danger" (click)="remove.emit(item)">
<span> <span>
<i class="fa fa-trash me-1" aria-hidden="true"></i> <i class="fa fa-trash me-1" aria-hidden="true"></i>
</span> </span>
<span class="d-none d-sm-inline-block">{{t('remove')}}</span> <span class="d-none d-md-inline-block">{{t('remove')}}</span>
</button> </button>
<button class="btn btn-primary ms-2" (click)="readChapter(item)"> <button class="btn btn-primary ms-2" (click)="readChapter(item)">
<span> <span>
<i class="fa fa-book me-1" aria-hidden="true"></i> <i class="fa fa-book me-1" aria-hidden="true"></i>
</span> </span>
<span class="d-none d-sm-inline-block">{{t('read')}}</span> <span class="d-none d-md-inline-block">{{t('read')}}</span>
</button> </button>
</div> </div>

View File

@ -34,3 +34,44 @@ $image-height: 125px;
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0; border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
border-color: transparent var(--primary-color) transparent transparent; border-color: transparent var(--primary-color) transparent transparent;
} }
::ng-deep .read-more-cont div {
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
display:-webkit-box;
;
}
@media (max-width: 576px) {
::ng-deep .read-more-cont div {
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
display:-webkit-box;
;
}
}
@media (max-width: 800px) {
::ng-deep .read-more-cont div {
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
display:-webkit-box;
;
}
}
@media (max-width: 768px) {
.actions {
display:flex;
flex-direction: column-reverse;
margin-left: 10px;
.btn-primary {
margin-bottom: 10px;
margin-left: 0 !important;
}
}
}

View File

@ -1,8 +1,46 @@
<ng-container *transloco="let t; read: 'external-rating'"> <ng-container *transloco="let t; read: 'external-rating'">
<div class="row g-0"> <div class="row g-0">
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
[popoverTitle]="t('kavita-tooltip')" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
<span class="badge rounded-pill ps-0 me-1"> @if (utilityService.activeBreakpoint$ | async; as activeBreakpoint) {
@if (activeBreakpoint <= Breakpoint.Tablet) {
<div class="col-auto custom-col clickable" tabindex="0" role="button" (click)="openRatingModal()">
<ng-container [ngTemplateOutlet]="kavitaRating"></ng-container>
</div>
} @else {
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
[popoverTitle]="t('kavita-tooltip')" popoverClass="md-popover">
<ng-container [ngTemplateOutlet]="kavitaRating"></ng-container>
</div>
}
}
@for (rating of ratings; track rating.provider + rating.averageScore) {
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
<span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
{{rating.averageScore}}%
</span>
</div>
}
<div class="col-auto" style="padding-top: 8px">
<app-loading [loading]="isLoading" size="spinner-border-sm"></app-loading>
</div>
<div class="col-auto ms-2" style="padding-top: 4px">
@for(link of webLinks; track link) {
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
}
</div>
</div>
<ng-template #kavitaRating>
<span class="badge rounded-pill ps-0 me-1">
<app-image classes="me-1" imageUrl="assets/images/logo-32.png" width="24px" height="24px" /> <app-image classes="me-1" imageUrl="assets/images/logo-32.png" width="24px" height="24px" />
@if (hasUserRated) { @if (hasUserRated) {
{{userRating * 20}} {{userRating * 20}}
@ -16,45 +54,23 @@
@if (hasUserRated || overallRating > 0) { @if (hasUserRated || overallRating > 0) {
% %
} }
</span> </span>
</div> </ng-template>
@for (rating of ratings; track rating.provider + rating.averageScore) {
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
<span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
{{rating.averageScore}}%
</span>
</div>
}
<div class="col-auto" style="padding-top: 8px">
<app-loading [loading]="isLoading" size="spinner-border-sm"></app-loading>
</div>
<div class="col-auto ms-2">
@for(link of webLinks; track link) {
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
[errorImage]="imageService.errorWebLinkImage"></app-image>
</a>
}
</div>
</div>
<ng-template #popContent> <ng-template #popContent>
<ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)" [size]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 1 : 2" <ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)" [size]="2"
[maxStars]="5" [color]="starColor"></ngx-stars> [maxStars]="5" [color]="starColor"></ngx-stars>
{{userRating * 20}}% {{userRating * 20}}%
</ng-template> </ng-template>
<ng-template #externalPopContent let-rating="rating"> <ng-template #externalPopContent let-rating="rating">
<div><i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}</div> <div>
<i class="fa-solid fa-heart" aria-hidden="true"></i> {{rating.favoriteCount}}
</div>
@if (rating.providerUrl) { @if (rating.providerUrl) {
<a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a> <a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a>
} }
</ng-template> </ng-template>
</ng-container> </ng-container>

View File

@ -7,11 +7,10 @@ import {
OnInit, OnInit,
ViewEncapsulation ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {SeriesService} from "../../../_services/series.service"; import {SeriesService} from "../../../_services/series.service";
import {Rating} from "../../../_models/rating"; import {Rating} from "../../../_models/rating";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe"; import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {NgbPopover, NgbRating} from "@ng-bootstrap/ng-bootstrap"; import {NgbModal, NgbPopover, NgbRating} from "@ng-bootstrap/ng-bootstrap";
import {LoadingComponent} from "../../../shared/loading/loading.component"; import {LoadingComponent} from "../../../shared/loading/loading.component";
import {LibraryType} from "../../../_models/library/library"; import {LibraryType} from "../../../_models/library/library";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe"; import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
@ -22,11 +21,14 @@ import {ImageComponent} from "../../../shared/image/image.component";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {ImageService} from "../../../_services/image.service"; import {ImageService} from "../../../_services/image.service";
import {AsyncPipe, NgOptimizedImage, NgTemplateOutlet} from "@angular/common";
import {InviteUserComponent} from "../../../admin/invite-user/invite-user.component";
import {RatingModalComponent} from "../rating-modal/rating-modal.component";
@Component({ @Component({
selector: 'app-external-rating', selector: 'app-external-rating',
standalone: true, standalone: true,
imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective, SafeHtmlPipe], imports: [ProviderImagePipe, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective, SafeHtmlPipe, NgOptimizedImage, AsyncPipe, NgTemplateOutlet],
templateUrl: './external-rating.component.html', templateUrl: './external-rating.component.html',
styleUrls: ['./external-rating.component.scss'], styleUrls: ['./external-rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -40,6 +42,7 @@ export class ExternalRatingComponent implements OnInit {
public readonly utilityService = inject(UtilityService); public readonly utilityService = inject(UtilityService);
public readonly destroyRef = inject(DestroyRef); public readonly destroyRef = inject(DestroyRef);
public readonly imageService = inject(ImageService); public readonly imageService = inject(ImageService);
public readonly modalService = inject(NgbModal);
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
@ -65,4 +68,17 @@ export class ExternalRatingComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
openRatingModal() {
const modalRef = this.modalService.open(RatingModalComponent, {size: 'xl'});
modalRef.componentInstance.userRating = this.userRating;
modalRef.componentInstance.seriesId = this.seriesId;
modalRef.componentInstance.hasUserRated = this.hasUserRated;
modalRef.closed.subscribe((updated: {hasUserRated: boolean, userRating: number}) => {
this.userRating = updated.userRating;
this.hasUserRated = this.hasUserRated || updated.hasUserRated;
this.cdRef.markForCheck();
});
}
} }

View File

@ -11,6 +11,10 @@
<app-age-rating-image [rating]="ageRating"></app-age-rating-image> <app-age-rating-image [rating]="ageRating"></app-age-rating-image>
</span> </span>
<span class="me-2">
<app-series-format [format]="mangaFormat" [useTitle]="false"></app-series-format>
</span>
@if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) { @if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) {
<span class="word-count me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span> <span class="word-count me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
} @else { } @else {

View File

@ -15,6 +15,10 @@ import {ImageService} from "../../../_services/image.service";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.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 {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
import {MangaFormat} from "../../../_models/manga-format";
import {MangaFormatIconPipe} from "../../../_pipes/manga-format-icon.pipe";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
@Component({ @Component({
selector: 'app-metadata-detail-row', selector: 'app-metadata-detail-row',
@ -26,7 +30,10 @@ import {FilterField} from "../../../_models/metadata/v2/filter-field";
ReadTimePipe, ReadTimePipe,
NgbTooltip, NgbTooltip,
TranslocoDirective, TranslocoDirective,
ImageComponent ImageComponent,
MangaFormatPipe,
MangaFormatIconPipe,
SeriesFormatComponent
], ],
templateUrl: './metadata-detail-row.component.html', templateUrl: './metadata-detail-row.component.html',
styleUrl: './metadata-detail-row.component.scss', styleUrl: './metadata-detail-row.component.scss',
@ -44,6 +51,7 @@ export class MetadataDetailRowComponent {
@Input() readingTimeLeft: HourEstimateRange | null = null; @Input() readingTimeLeft: HourEstimateRange | null = null;
@Input({required: true}) ageRating: AgeRating = AgeRating.Unknown; @Input({required: true}) ageRating: AgeRating = AgeRating.Unknown;
@Input({required: true}) libraryType!: LibraryType; @Input({required: true}) libraryType!: LibraryType;
@Input({required: true}) mangaFormat!: MangaFormat;
openGeneric(queryParamName: FilterField, filter: string | number) { openGeneric(queryParamName: FilterField, filter: string | number) {
if (queryParamName === FilterField.None) return; if (queryParamName === FilterField.None) return;

View File

@ -4,7 +4,7 @@
<h5>{{heading}}</h5> <h5>{{heading}}</h5>
</div> </div>
<div class="col-lg-9 col-md-8 col-sm-12"> <div class="col-lg-9 col-md-8 col-sm-12">
<app-badge-expander [items]="tags" [itemsTillExpander]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 30 : 4"> <app-badge-expander [items]="tags" [itemsTillExpander]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 30 : 4" [includeComma]="includeComma">
<ng-template #badgeExpanderItem let-item let-position="idx"> <ng-template #badgeExpanderItem let-item let-position="idx">
@if(itemTemplate) { @if(itemTemplate) {
<span (click)="goTo(queryParam, item.id)"> <span (click)="goTo(queryParam, item.id)">

View File

@ -28,6 +28,7 @@ export class MetadataDetailComponent {
@Input({required: true}) libraryId!: number; @Input({required: true}) libraryId!: number;
@Input({required: true}) heading!: string; @Input({required: true}) heading!: string;
@Input() queryParam: FilterField = FilterField.None; @Input() queryParam: FilterField = FilterField.None;
@Input() includeComma: boolean = true;
@ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>; @ContentChild('titleTemplate') titleTemplate!: TemplateRef<any>;
@ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>; @ContentChild('itemTemplate') itemTemplate?: TemplateRef<any>;

View File

@ -0,0 +1,16 @@
<ng-container *transloco="let t; read: 'external-rating'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('kavita-rating-title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="row g-0">
<ngx-stars [initialStars]="userRating" (ratingOutput)="updateRating($event)" [size]="2"
[maxStars]="5" [color]="starColor"></ngx-stars>
{{userRating * 20}}%
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</ng-container>

View File

@ -0,0 +1,46 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input} from '@angular/core';
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@jsverse/transloco";
import {Breakpoint} from "../../../shared/_services/utility.service";
import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../../_services/theme.service";
import {SeriesService} from "../../../_services/series.service";
@Component({
selector: 'app-rating-modal',
standalone: true,
imports: [
TranslocoDirective,
NgxStarsModule
],
templateUrl: './rating-modal.component.html',
styleUrl: './rating-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RatingModalComponent {
protected readonly modal = inject(NgbActiveModal);
protected readonly themeService = inject(ThemeService);
protected readonly seriesService = inject(SeriesService);
protected readonly cdRef = inject(ChangeDetectorRef);
protected readonly Breakpoint = Breakpoint;
@Input({required: true}) userRating!: number;
@Input({required: true}) seriesId!: number;
@Input({required: true}) hasUserRated!: boolean;
starColor = this.themeService.getCssVariable('--rating-star-color');
updateRating(rating: number) {
this.seriesService.updateRating(this.seriesId, rating).subscribe(() => {
this.userRating = rating;
this.hasUserRated = true;
this.cdRef.markForCheck();
});
}
close() {
this.modal.close({hasUserRated: this.hasUserRated, userRating: this.userRating});
}
}

View File

@ -4,9 +4,12 @@
@if (series && seriesMetadata && libraryType !== null) { @if (series && seriesMetadata && libraryType !== null) {
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock> <div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock>
<div class="row mb-0 mb-xl-3 info-container"> <div class="row mb-0 mb-xl-3 info-container">
<div class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3 position-relative"> <div [ngClass]="mobileSeriesImgBackground === 'true' ? 'mobile-bg' : ''" class="image-container col-5 col-sm-6 col-md-5 col-lg-5 col-xl-2 col-xxl-2 col-xxxl-2 d-none d-sm-block mb-3 position-relative">
@if(mobileSeriesImgBackground === 'true') {
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="seriesImage"></app-image> <app-image [styles]="{'background': 'none'}" [imageUrl]="seriesImage"></app-image>
} @else {
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="seriesImage"></app-image>
}
@if (series.pagesRead < series.pages && hasReadingProgress) { @if (series.pagesRead < series.pages && hasReadingProgress) {
<div class="progress-banner series" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}%"> <div class="progress-banner series" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}%">
<ngb-progressbar type="primary" [value]="series.pagesRead" [max]="series.pages" [showValue]="true"></ngb-progressbar> <ngb-progressbar type="primary" [value]="series.pagesRead" [max]="series.pages" [showValue]="true"></ngb-progressbar>
@ -29,7 +32,7 @@
</div> </div>
</div> </div>
<div class="col-xl-10 col-lg-7 col-md-7 col-xs-8 col-sm-6"> <div class="col-xl-10 col-lg-7 col-md-12 col-xs-12 col-sm-12">
<h4 class="title mb-2"> <h4 class="title mb-2">
<span>{{series.name}} <span>{{series.name}}
@ -52,7 +55,8 @@
[ageRating]="seriesMetadata.ageRating" [ageRating]="seriesMetadata.ageRating"
[hasReadingProgress]="hasReadingProgress" [hasReadingProgress]="hasReadingProgress"
[readingTimeEntity]="series" [readingTimeEntity]="series"
[libraryType]="libraryType"> [libraryType]="libraryType"
[mangaFormat]="series.format">
</app-metadata-detail-row> </app-metadata-detail-row>
<!-- Rating goes here (after I implement support for rating individual issues --> <!-- Rating goes here (after I implement support for rating individual issues -->
@ -133,12 +137,13 @@
<div class="mt-2 upper-details"> <div class="mt-2 upper-details">
<div class="row g-0"> <div class="row g-0">
<div class="col-6"> <div class="col-6 pe-5">
<span class="fw-bold">{{t('writers-title')}}</span> <span class="fw-bold">{{t('writers-title')}}</span>
<div> <div>
<app-badge-expander [items]="seriesMetadata.writers" <app-badge-expander [items]="seriesMetadata.writers"
[itemsTillExpander]="3" [itemsTillExpander]="3"
[allowToggle]="false"> [allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last"> <ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Writers, item.id)">{{item.name}}</a> <a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Writers, item.id)">{{item.name}}</a>
</ng-template> </ng-template>
@ -162,12 +167,13 @@
<div class="mt-3 mb-2 upper-details"> <div class="mt-3 mb-2 upper-details">
<div class="row g-0"> <div class="row g-0">
<div class="col-6"> <div class="col-6 pe-5">
<span class="fw-bold">{{t('genres-title')}}</span> <span class="fw-bold">{{t('genres-title')}}</span>
<div> <div>
<app-badge-expander [items]="seriesMetadata.genres" <app-badge-expander [items]="seriesMetadata.genres"
[itemsTillExpander]="3" [itemsTillExpander]="3"
[allowToggle]="false"> [allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last"> <ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Genres, item.id)">{{item.title}}</a> <a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Genres, item.id)">{{item.title}}</a>
</ng-template> </ng-template>
@ -180,7 +186,8 @@
<div> <div>
<app-badge-expander [items]="seriesMetadata.tags" <app-badge-expander [items]="seriesMetadata.tags"
[itemsTillExpander]="3" [itemsTillExpander]="3"
[allowToggle]="false"> [allowToggle]="false"
(toggle)="switchTabsToDetail()">
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last"> <ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Tags, item.id)">{{item.title}}</a> <a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Tags, item.id)">{{item.title}}</a>
</ng-template> </ng-template>
@ -189,45 +196,11 @@
</div> </div>
</div> </div>
</div> </div>
<!-- <div class="mt-3 mb-2">-->
<!-- <div class="row g-0">-->
<!-- <div class="col-6">-->
<!-- <span class="fw-bold">{{t('weblinks-title')}}</span>-->
<!-- <div>-->
<!-- @for(link of WebLinks; track link) {-->
<!-- <a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">-->
<!-- <app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"-->
<!-- [errorImage]="imageService.errorWebLinkImage"></app-image>-->
<!-- </a>-->
<!-- } @empty {-->
<!-- {{null | defaultValue}}-->
<!-- }-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="col-6">-->
<!-- <span class="fw-bold">{{t('publication-status-title')}}</span>-->
<!-- <div>-->
<!-- @if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {-->
<!-- <a class="dark-exempt btn-icon" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"-->
<!-- href="javascript:void(0);"-->
<!-- [ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">-->
<!-- {{pubStatus}}-->
<!-- </a>-->
<!-- }-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
</div> </div>
</div> </div>
<div class="carousel-tabs-container"> <div class="carousel-tabs-container mb-2">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)"> <ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs" [destroyOnHide]="false" (navChange)="onNavChange($event)">
@if (showStorylineTab) { @if (showStorylineTab) {
<li [ngbNavItem]="TabID.Storyline"> <li [ngbNavItem]="TabID.Storyline">
@ -316,138 +289,16 @@
</li> </li>
} }
@if (hasRelations && relationShips) { @if (hasRelations || readingLists.length > 0 || collections.length > 0) {
<li [ngbNavItem]="TabID.Related"> <li [ngbNavItem]="TabID.Related">
<a ngbNavLink> <a ngbNavLink>
{{t(TabID.Related)}} {{t(TabID.Related)}}
<span class="badge rounded-pill text-bg-secondary">{{relations.length}}</span> <span class="badge rounded-pill text-bg-secondary">{{relations.length + readingLists.length + collections.length}}</span>
</a> </a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@defer (when activeTabId === TabID.Related; prefetch on idle) { @defer (when activeTabId === TabID.Related; prefetch on idle) {
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1"> <app-related-tab [readingLists]="readingLists" [collections]="collections" [relations]="relations"></app-related-tab>
<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" [series]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
}
</div>
</virtual-scroller>
} }
<!-- @if (relationShips.prequels.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.prequels" title="Prequels">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Prequel"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.sequels.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.sequels" title="Sequels">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Sequel"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.parent.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.parent" title="Parent">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Parent"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.contains.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.contains" title="Contains">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Contains"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.adaptations.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.adaptations" title="Contains">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Adaptation"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.annuals.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.annuals" title="Annuals">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Annual"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.characters.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.characters" title="Characters">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Character"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.alternativeSettings.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.alternativeSettings" title="Alternative Settings">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.AlternativeSetting"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.doujinshis.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.doujinshis" title="Doujinshis">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Doujinshi"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.alternativeVersions.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.alternativeVersions" title="Alternative Versions">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.AlternativeVersion"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.editions.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.editions" title="Editions">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Edition"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.others.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.others" title="Other">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.Other"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.sideStories.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.sideStories" title="Side Stories">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.SideStory"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
<!-- @if (relationShips.spinOffs.length > 0) {-->
<!-- <app-carousel-reel [items]="relationShips.spinOffs" title="Spin Offs">-->
<!-- <ng-template #carouselItem let-item>-->
<!-- <app-series-card class="col-auto mt-2 mb-2" [series]="item" [libraryId]="item.libraryId" [relation]="RelationKind.SpinOff"></app-series-card>-->
<!-- </ng-template>-->
<!-- </app-carousel-reel>-->
<!-- }-->
</ng-template> </ng-template>
</li> </li>
} }

View File

@ -7,19 +7,7 @@
left: 20px; left: 20px;
} }
.under-image {
background-color: var(--breadcrumb-bg-color);
color: white;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
text-align: center;
}
//
//.rating-star {
// margin-top: 2px;
// font-size: 1.5rem;
//}
//
.card-container{ .card-container{
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, 160px); grid-template-columns: repeat(auto-fill, 160px);
@ -27,7 +15,7 @@
justify-content: space-around; justify-content: space-around;
} }
::ng-deep .carousel-container .header i.fa-plus, ::ng-deep .carousel-container .header i.fa-pen{ ::ng-deep .carousel-container .header i.fa-plus, ::ng-deep .carousel-container .header i.fa-pen {
border-width: 1px; border-width: 1px;
border-style: solid; border-style: solid;
border-radius: 5px; border-radius: 5px;
@ -40,13 +28,4 @@
} }
} }
.upper-details {
font-size: 0.9rem;
}
@media (max-width: 768px) {
.carousel-tabs-container {
mask-image: linear-gradient(transparent, black 0%, black 90%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent, black 0%, black 90%, transparent 100%);
}
}

View File

@ -98,10 +98,6 @@ import {
} 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 {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {translate, TranslocoDirective, TranslocoService} from "@jsverse/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 {
SeriesPreviewDrawerComponent
} from "../../../_single-module/series-preview-drawer/series-preview-drawer.component";
import {PublicationStatus} from "../../../_models/metadata/publication-status"; import {PublicationStatus} from "../../../_models/metadata/publication-status";
import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter"; import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter";
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component"; import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
@ -143,11 +139,13 @@ import {MetadataDetailRowComponent} from "../metadata-detail-row/metadata-detail
import {DownloadButtonComponent} from "../download-button/download-button.component"; import {DownloadButtonComponent} from "../download-button/download-button.component";
import {hasAnyCast} from "../../../_models/common/i-has-cast"; import {hasAnyCast} from "../../../_models/common/i-has-cast";
import {EditVolumeModalComponent} from "../../../_single-module/edit-volume-modal/edit-volume-modal.component"; import {EditVolumeModalComponent} from "../../../_single-module/edit-volume-modal/edit-volume-modal.component";
import {CoverUpdateEvent} from "../../../_models/events/cover-update-event";
import {RelatedSeriesPair, RelatedTabComponent} from "../../../_single-modules/related-tab/related-tab.component";
import {CollectionTagService} from "../../../_services/collection-tag.service";
import {UserCollection} from "../../../_models/collection-tag";
import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component";
import {MangaFormatPipe} from "../../../_pipes/manga-format.pipe";
interface RelatedSeriesPair {
series: Series;
relation: RelationKind;
}
enum TabID { enum TabID {
Related = 'related-tab', Related = 'related-tab',
@ -176,12 +174,12 @@ interface StoryLineItem {
TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
NgbDropdownItem, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbDropdownItem, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent,
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, CardItemComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, CardItemComponent,
EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe, PersonBadgeComponent, DetailsTabComponent, ChapterCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe, PersonBadgeComponent, DetailsTabComponent, ChapterCardComponent,
VolumeCardComponent, JsonPipe, AgeRatingPipe, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, ReadTimePipe, VolumeCardComponent, JsonPipe, AgeRatingPipe, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, ReadTimePipe,
RouterLink, TimeAgoPipe, AgeRatingImageComponent, CompactNumberPipe, IconAndTitleComponent, SafeHtmlPipe, BadgeExpanderComponent, RouterLink, TimeAgoPipe, AgeRatingImageComponent, CompactNumberPipe, IconAndTitleComponent, SafeHtmlPipe, BadgeExpanderComponent,
A11yClickDirective, ReadTimeLeftPipe, PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent] A11yClickDirective, ReadTimeLeftPipe, PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, SeriesFormatComponent, MangaFormatPipe]
}) })
export class SeriesDetailComponent implements OnInit, AfterContentChecked { export class SeriesDetailComponent implements OnInit, AfterContentChecked {
@ -200,10 +198,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
private readonly actionService = inject(ActionService); private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService); private readonly messageHub = inject(MessageHubService);
private readonly readingListService = inject(ReadingListService); private readonly readingListService = inject(ReadingListService);
private readonly offcanvasService = inject(NgbOffcanvas); private readonly collectionTagService = inject(CollectionTagService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
private readonly deviceService = inject(DeviceService);
private readonly translocoService = inject(TranslocoService); private readonly translocoService = inject(TranslocoService);
protected readonly bulkSelectionService = inject(BulkSelectionService); protected readonly bulkSelectionService = inject(BulkSelectionService);
protected readonly utilityService = inject(UtilityService); protected readonly utilityService = inject(UtilityService);
@ -243,6 +240,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
isLoadingExtra = false; isLoadingExtra = false;
libraryAllowsScrobbling = false; libraryAllowsScrobbling = false;
isScrobbling: boolean = true; isScrobbling: boolean = true;
mobileSeriesImgBackground: string | undefined;
currentlyReadingChapter: Chapter | undefined = undefined; currentlyReadingChapter: Chapter | undefined = undefined;
hasReadingProgress = false; hasReadingProgress = false;
@ -262,6 +260,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
libraryType: LibraryType = LibraryType.Manga; libraryType: LibraryType = LibraryType.Manga;
seriesMetadata: SeriesMetadata | null = null; seriesMetadata: SeriesMetadata | null = null;
readingLists: Array<ReadingList> = []; readingLists: Array<ReadingList> = [];
collections: Array<UserCollection> = [];
isWantToRead: boolean = false; isWantToRead: boolean = false;
unreadCount: number = 0; unreadCount: number = 0;
totalCount: number = 0; totalCount: number = 0;
@ -384,6 +383,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
} }
} }
get UseBookLogic() { get UseBookLogic() {
return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel; return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel;
} }
@ -472,6 +472,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return; return;
} }
this.mobileSeriesImgBackground = getComputedStyle(document.documentElement)
.getPropertyValue('--mobile-series-img-background').trim();
// Set up 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) => {
@ -486,12 +489,15 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.router.navigateByUrl('/home'); this.router.navigateByUrl('/home');
} }
} else if (event.event === EVENTS.ScanSeries) { } else if (event.event === EVENTS.ScanSeries) {
const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent; const seriesScanEvent = event.payload as ScanSeriesEvent;
if (seriesCoverUpdatedEvent.seriesId === this.seriesId) { if (seriesScanEvent.seriesId === this.seriesId) {
this.loadSeries(this.seriesId); this.loadSeries(this.seriesId);
} }
} else if (event.event === EVENTS.CoverUpdate) { } else if (event.event === EVENTS.CoverUpdate) {
this.themeService.refreshColorScape('series', this.seriesId).subscribe(); const coverUpdateEvent = event.payload as CoverUpdateEvent;
if (coverUpdateEvent.id === this.seriesId) {
this.themeService.refreshColorScape('series', this.seriesId).subscribe();
}
} else if (event.event === EVENTS.ChapterRemoved) { } else if (event.event === EVENTS.ChapterRemoved) {
const removedEvent = event.payload as ChapterRemovedEvent; const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return; if (removedEvent.seriesId !== this.seriesId) return;
@ -554,13 +560,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
updateUrl(activeTab: TabID) { updateUrl(activeTab: TabID) {
var tokens = this.router.url.split('#'); var tokens = this.router.url.split('#');
const newUrl = `${tokens[0]}#${activeTab}`; const newUrl = `${tokens[0]}#${activeTab}`;
window.history.replaceState({}, '', newUrl);
// if (tokens.length === 1 || tokens[1] === activeTab + '') {
// return;
// }
console.log('url:', newUrl);
//this.router.navigateByUrl(newUrl, { skipLocationChange: true, replaceUrl: true });
} }
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) { handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
@ -580,10 +580,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.actionService.scanSeries(series); this.actionService.scanSeries(series);
break; break;
case(Action.RefreshMetadata): case(Action.RefreshMetadata):
this.actionService.refreshSeriesMetadata(series, undefined, true); this.actionService.refreshSeriesMetadata(series, undefined, true, false);
break; break;
case(Action.GenerateColorScape): case(Action.GenerateColorScape):
this.actionService.refreshSeriesMetadata(series, undefined, false); this.actionService.refreshSeriesMetadata(series, undefined, false, true);
break; break;
case(Action.Delete): case(Action.Delete):
this.deleteSeries(series); this.deleteSeries(series);
@ -673,13 +673,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.openChapter(chapter, true); this.openChapter(chapter, true);
break; break;
case (Action.SendTo): case (Action.SendTo):
{ const device = (action._extra!.data as Device);
const device = (action._extra!.data as Device); this.actionService.sendToDevice([chapter.id], device);
this.deviceService.sendTo([chapter.id], device.id).subscribe(() => { break;
this.toastr.success(this.translocoService.translate('series-detail.send-to', {deviceName: device.name}));
});
break;
}
default: default:
break; break;
} }
@ -726,6 +722,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
this.collectionTagService.allCollectionsForSeries(seriesId, false).subscribe(tags => {
this.collections = tags;
this.cdRef.markForCheck();
})
this.readerService.getTimeLeft(seriesId).subscribe((timeLeft) => { this.readerService.getTimeLeft(seriesId).subscribe((timeLeft) => {
this.readingTimeLeft = timeLeft; this.readingTimeLeft = timeLeft;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -1147,23 +1148,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
previewSeries(item: Series | ExternalSeries, isExternal: boolean) {
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.isExternalSeries = isExternal;
ref.componentInstance.name = item.name;
if (isExternal) {
const external = item as ExternalSeries;
ref.componentInstance.aniListId = external.aniListId;
ref.componentInstance.malId = external.malId;
} else {
const local = item as Series;
ref.componentInstance.seriesId = local.id;
ref.componentInstance.libraryId = local.libraryId;
}
}
openFilter(field: FilterField, value: string | number) { openFilter(field: FilterField, value: string | number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
} }
@ -1183,4 +1167,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}); });
} }
} }
switchTabsToDetail() {
this.activeTabId = TabID.Details;
this.cdRef.markForCheck();
}
} }

View File

@ -1,12 +1,7 @@
<ng-container *transloco="let t;"> <ng-container *transloco="let t;">
<div class="container-fluid"> <div class="container-fluid">
<ng-content></ng-content> <ng-content></ng-content>
@if (subtitle) { @if (subtitle) {
<div class="description text-muted" [innerHTML]="subtitle | safeHtml"></div> <div class="description text-muted" [innerHTML]="subtitle | safeHtml"></div>
} }

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t;"> <ng-container *transloco="let t;">
<div class="container-fluid"> <div class="container-fluid">
<div class="row g-0"> <div class="row g-0">
<div class="col-11"> <div class="col-10">
<h6 class="section-title"> <h6 class="section-title">
@if(labelId) { @if(labelId) {
<label class="reset-label" [for]="labelId">{{title}}</label> <label class="reset-label" [for]="labelId">{{title}}</label>
@ -13,9 +13,9 @@
} }
</h6> </h6>
</div> </div>
<div class="col-1"> <div class="col-2 text-end align-self-end justify-content-end">
@if (showEdit) { @if (showEdit) {
<button class="btn btn-text btn-sm" (click)="toggleEditMode()" [disabled]="!canEdit"> <button type="button" class="btn btn-text btn-sm" (click)="toggleEditMode()" [disabled]="!canEdit">
{{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}} {{isEditMode ? t('common.close') : (editLabel || t('common.edit'))}}
</button> </button>
} }

View File

@ -1,15 +1,15 @@
<ng-container *transloco="let t;"> <ng-container *transloco="let t;">
<div class="container-fluid"> <div class="container-fluid">
<div class="row g-0 mb-2"> <div class="row g-0 mb-2">
<div class="col-11"> <div class="col-10">
<h6 class="section-title" [id]="id || title">{{title}} <h6 class="section-title" [id]="id || title">{{title}}
@if (titleExtraRef) { @if (titleExtraRef) {
<ng-container [ngTemplateOutlet]="titleExtraRef"></ng-container> <ng-container [ngTemplateOutlet]="titleExtraRef"></ng-container>
} }
</h6> </h6>
</div> </div>
<div class="col-1"> <div class="col-2 text-end align-self-end justify-content-end">
<button class="btn btn-text btn-sm" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button> <button type="button" class="btn btn-text btn-sm" (click)="toggleViewMode()" [disabled]="!canEdit">{{isEditMode ? t('common.close') : t('common.edit')}}</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -34,7 +34,7 @@
@defer (when fragment === SettingsTabId.Users; prefetch on idle) { @defer (when fragment === SettingsTabId.Users; prefetch on idle) {
@if (fragment === SettingsTabId.Users) { @if (fragment === SettingsTabId.Users) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-users></app-manage-users> <app-manage-users></app-manage-users>
</div> </div>
} }
@ -42,7 +42,7 @@
@defer (when fragment === SettingsTabId.Libraries; prefetch on idle) { @defer (when fragment === SettingsTabId.Libraries; prefetch on idle) {
@if (fragment === SettingsTabId.Libraries) { @if (fragment === SettingsTabId.Libraries) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-library></app-manage-library> <app-manage-library></app-manage-library>
</div> </div>
} }
@ -50,7 +50,7 @@
@defer (when fragment === SettingsTabId.MediaIssues; prefetch on idle) { @defer (when fragment === SettingsTabId.MediaIssues; prefetch on idle) {
@if (fragment === SettingsTabId.MediaIssues) { @if (fragment === SettingsTabId.MediaIssues) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-media-issues></app-manage-media-issues> <app-manage-media-issues></app-manage-media-issues>
</div> </div>
} }
@ -58,7 +58,7 @@
@defer (when fragment === SettingsTabId.System; prefetch on idle) { @defer (when fragment === SettingsTabId.System; prefetch on idle) {
@if (fragment === SettingsTabId.System) { @if (fragment === SettingsTabId.System) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-system></app-manage-system> <app-manage-system></app-manage-system>
</div> </div>
} }
@ -66,7 +66,7 @@
@defer (when fragment === SettingsTabId.Statistics; prefetch on idle) { @defer (when fragment === SettingsTabId.Statistics; prefetch on idle) {
@if (fragment === SettingsTabId.Statistics) { @if (fragment === SettingsTabId.Statistics) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-server-stats></app-server-stats> <app-server-stats></app-server-stats>
</div> </div>
} }
@ -74,7 +74,7 @@
@defer (when fragment === SettingsTabId.Tasks; prefetch on idle) { @defer (when fragment === SettingsTabId.Tasks; prefetch on idle) {
@if (fragment === SettingsTabId.Tasks) { @if (fragment === SettingsTabId.Tasks) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-tasks-settings></app-manage-tasks-settings> <app-manage-tasks-settings></app-manage-tasks-settings>
</div> </div>
} }
@ -82,7 +82,7 @@
@defer (when fragment === SettingsTabId.KavitaPlus; prefetch on idle) { @defer (when fragment === SettingsTabId.KavitaPlus; prefetch on idle) {
@if (fragment === SettingsTabId.KavitaPlus) { @if (fragment === SettingsTabId.KavitaPlus) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-kavitaplus></app-manage-kavitaplus> <app-manage-kavitaplus></app-manage-kavitaplus>
</div> </div>
} }
@ -114,7 +114,7 @@
@defer (when fragment === SettingsTabId.Customize; prefetch on idle) { @defer (when fragment === SettingsTabId.Customize; prefetch on idle) {
@if (fragment === SettingsTabId.Customize) { @if (fragment === SettingsTabId.Customize) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-customization></app-manage-customization> <app-manage-customization></app-manage-customization>
</div> </div>
} }
@ -130,7 +130,7 @@
@defer (when fragment === SettingsTabId.Theme; prefetch on idle) { @defer (when fragment === SettingsTabId.Theme; prefetch on idle) {
@if (fragment === SettingsTabId.Theme) { @if (fragment === SettingsTabId.Theme) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-theme-manager></app-theme-manager> <app-theme-manager></app-theme-manager>
</div> </div>
} }
@ -138,7 +138,7 @@
@defer (when fragment === SettingsTabId.Devices; prefetch on idle) { @defer (when fragment === SettingsTabId.Devices; prefetch on idle) {
@if (fragment === SettingsTabId.Devices) { @if (fragment === SettingsTabId.Devices) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-devices></app-manage-devices> <app-manage-devices></app-manage-devices>
</div> </div>
} }
@ -146,7 +146,7 @@
@defer (when fragment === SettingsTabId.UserStats; prefetch on idle) { @defer (when fragment === SettingsTabId.UserStats; prefetch on idle) {
@if (fragment === SettingsTabId.UserStats) { @if (fragment === SettingsTabId.UserStats) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-user-stats></app-user-stats> <app-user-stats></app-user-stats>
</div> </div>
} }
@ -154,7 +154,7 @@
@defer (when fragment === SettingsTabId.CBLImport; prefetch on idle) { @defer (when fragment === SettingsTabId.CBLImport; prefetch on idle) {
@if (fragment === SettingsTabId.CBLImport) { @if (fragment === SettingsTabId.CBLImport) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-import-cbl></app-import-cbl> <app-import-cbl></app-import-cbl>
</div> </div>
} }
@ -162,7 +162,7 @@
@defer (when fragment === SettingsTabId.Scrobbling; prefetch on idle) { @defer (when fragment === SettingsTabId.Scrobbling; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.Scrobbling) { @if(hasActiveLicense && fragment === SettingsTabId.Scrobbling) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-manage-scrobling></app-manage-scrobling> <app-manage-scrobling></app-manage-scrobling>
</div> </div>
} }
@ -170,7 +170,7 @@
@defer (when fragment === SettingsTabId.MALStackImport; prefetch on idle) { @defer (when fragment === SettingsTabId.MALStackImport; prefetch on idle) {
@if(hasActiveLicense && fragment === SettingsTabId.MALStackImport) { @if(hasActiveLicense && fragment === SettingsTabId.MALStackImport) {
<div class="col-md-12"> <div class="scale col-md-12">
<app-import-mal-collection></app-import-mal-collection> <app-import-mal-collection></app-import-mal-collection>
</div> </div>
} }

View File

@ -2,3 +2,9 @@ h2 {
color: white; color: white;
font-weight: bold; font-weight: bold;
} }
::ng-deep .content-wrapper:not(.closed) {
.scale {
width: calc(100dvw - 200px) !important;
}
}

Some files were not shown because too many files have changed in this diff Show More