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>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Delete Files="../openapi.json" />
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Delete Files="../openapi.json" />-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>

View File

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

View File

@ -310,9 +310,9 @@ public class LibraryController : BaseApiController
[Authorize(Policy = "RequireAdminRole")]
[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();
}

View File

@ -835,16 +835,26 @@ public class ReaderController : BaseApiController
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")]
public async Task<ActionResult> DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(title)) return BadRequest(await _localizationService.Translate(userId, "name-required"));
if (pageNum < 0) return BadRequest(await _localizationService.Translate(userId, "valid-number"));
var toc = await _unitOfWork.UserTableOfContentRepository.Get(userId, chapterId, pageNum, title);
if (toc == null) return Ok();
_unitOfWork.UserTableOfContentRepository.Remove(toc);
await _unitOfWork.CommitAsync();
return Ok();
}

View File

@ -402,7 +402,7 @@ public class SeriesController : BaseApiController
[HttpPost("refresh-metadata")]
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();
}

View File

@ -18,4 +18,9 @@ public class RefreshSeriesDto
/// </summary>
/// <remarks>This is expensive if true. Defaults to true.</remarks>
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 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
@ -486,9 +486,11 @@ public class ImageService : IImageService
// Resize the image to speed up processing
var resizedImage = image.Resize(0.1);
var processedImage = PreProcessImage(resizedImage);
// Convert image to RGB array
var pixels = resizedImage.WriteToMemory().ToArray();
var pixels = processedImage.WriteToMemory().ToArray();
// Convert to list of Vector3 (RGB)
var rgbPixels = new List<Vector3>();
@ -502,6 +504,9 @@ public class ImageService : IImageService
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)
{
return (sorted[0], sorted[1]);
@ -535,17 +540,18 @@ public class ImageService : IImageService
private static Image PreProcessImage(Image image)
{
return image;
// Create a mask for white and black pixels
var whiteMask = image.Colourspace(Enums.Interpretation.Lab)[0] > (WhiteThreshold * 100);
var blackMask = image.Colourspace(Enums.Interpretation.Lab)[0] < (BlackThreshold * 100);
// 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
var processedImage = image.Copy();
processedImage = processedImage.Ifthenelse(whiteMask, replacementColor);
processedImage = processedImage.Ifthenelse(blackMask, replacementColor);
//processedImage = processedImage.Ifthenelse(blackMask, replacementColor);
return processedImage;
}
@ -627,6 +633,13 @@ public class ImageService : IImageService
}).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)
{
return $"#{(int)color.X:X2}{(int)color.Y:X2}{(int)color.Z:X2}";

View File

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
@ -27,7 +26,7 @@ public interface IMetadataService
/// <param name="forceUpdate"></param>
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false);
Task GenerateCoversForLibrary(int libraryId, bool forceUpdate = false, bool forceColorScape = false);
/// <summary>
/// Performs a forced refresh of cover images just for a series and it's nested entities
/// </summary>
@ -35,8 +34,8 @@ public interface IMetadataService
/// <param name="seriesId"></param>
/// <param name="forceUpdate">Overrides any cache logic and forces execution</param>
Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true);
Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false);
Task GenerateCoversForSeries(int libraryId, int seriesId, bool forceUpdate = true, bool forceColorScape = true);
Task GenerateCoversForSeries(Series series, EncodeFormat encodeFormat, CoverImageSize coverImageSize, bool forceUpdate = false, bool forceColorScape = true);
Task RemoveAbandonedMetadataKeys();
}
@ -75,7 +74,8 @@ public class MetadataService : IMetadataService
/// <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="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);
@ -86,7 +86,7 @@ public class MetadataService : IMetadataService
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, chapter.CoverImage),
firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked))
{
if (NeedsColorSpace(chapter))
if (NeedsColorSpace(chapter, forceColorScape))
{
_imageService.UpdateColorScape(chapter);
_unitOfWork.ChapterRepository.Update(chapter);
@ -118,9 +118,11 @@ public class MetadataService : IMetadataService
firstFile.UpdateLastModified();
}
private static bool NeedsColorSpace(IHasCoverImage? entity)
private static bool NeedsColorSpace(IHasCoverImage? entity, bool force)
{
if (entity == null) return false;
if (force) return true;
return !string.IsNullOrEmpty(entity.CoverImage) &&
(string.IsNullOrEmpty(entity.PrimaryColor) || string.IsNullOrEmpty(entity.SecondaryColor));
}
@ -132,7 +134,8 @@ public class MetadataService : IMetadataService
/// </summary>
/// <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>
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
if (volume == null) return Task.FromResult(false);
@ -141,7 +144,7 @@ public class MetadataService : IMetadataService
_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, volume.CoverImage),
null, volume.Created, forceUpdate))
{
if (NeedsColorSpace(volume))
if (NeedsColorSpace(volume, forceColorScape))
{
_imageService.UpdateColorScape(volume);
_unitOfWork.VolumeRepository.Update(volume);
@ -176,7 +179,7 @@ public class MetadataService : IMetadataService
/// </summary>
/// <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>
private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate)
private Task UpdateSeriesCoverImage(Series? series, bool forceUpdate, bool forceColorScape = false)
{
if (series == null) return Task.CompletedTask;
@ -185,13 +188,12 @@ public class MetadataService : IMetadataService
null, series.Created, forceUpdate, series.CoverImageLocked))
{
// Check if we don't have a primary/seconary color
if (NeedsColorSpace(series))
if (NeedsColorSpace(series, forceColorScape))
{
_imageService.UpdateColorScape(series);
_updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series));
}
return Task.CompletedTask;
}
@ -211,7 +213,7 @@ public class MetadataService : IMetadataService
/// <param name="series"></param>
/// <param name="forceUpdate"></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);
try
@ -224,7 +226,7 @@ public class MetadataService : IMetadataService
var index = 0;
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
UpdateChapterLastModified(chapter, forceUpdate || chapterUpdated);
if (index == 0 && chapterUpdated)
@ -235,7 +237,7 @@ public class MetadataService : IMetadataService
index++;
}
var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate);
var volumeUpdated = await UpdateVolumeCoverImage(volume, firstChapterUpdated || forceUpdate, forceColorScape);
if (volumeIndex == 0 && volumeUpdated)
{
firstVolumeUpdated = true;
@ -243,7 +245,7 @@ public class MetadataService : IMetadataService
volumeIndex++;
}
await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate);
await UpdateSeriesCoverImage(series, firstVolumeUpdated || forceUpdate, forceColorScape);
}
catch (Exception ex)
{
@ -258,9 +260,10 @@ public class MetadataService : IMetadataService
/// <remarks>This can be heavy on memory first run</remarks>
/// <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="forceColorScape">Force updating colorscape</param>
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[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);
if (library == null) return;
@ -308,7 +311,7 @@ public class MetadataService : IMetadataService
try
{
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize);
await ProcessSeriesCoverGen(series, forceUpdate, encodeFormat, coverImageSize, forceColorScape);
}
catch (Exception ex)
{
@ -349,7 +352,8 @@ public class MetadataService : IMetadataService
/// <param name="libraryId"></param>
/// <param name="seriesId"></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);
if (series == null)
@ -361,7 +365,8 @@ public class MetadataService : IMetadataService
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var encodeFormat = settings.EncodeMediaAs;
var coverImageSize = settings.CoverImageSize;
await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate);
await GenerateCoversForSeries(series, encodeFormat, coverImageSize, forceUpdate, forceColorScape);
}
/// <summary>
@ -370,13 +375,14 @@ public class MetadataService : IMetadataService
/// <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="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();
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
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())

View File

@ -10,6 +10,7 @@ namespace API.Services;
public static class ReviewService
{
private const int BodyTextLimit = 175;
public static IEnumerable<UserReviewDto> SelectSpectrumOfReviews(IList<UserReviewDto> reviews)
{
IList<UserReviewDto> externalReviews;
@ -76,7 +77,7 @@ public static class ReviewService
plainText = Regex.Replace(plainText, @"__", string.Empty);
// 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 + "…";
}

View File

@ -27,8 +27,8 @@ public interface ITaskScheduler
Task ScanLibrary(int libraryId, bool force = false);
Task ScanLibraries(bool force = false);
void CleanupChapters(int[] chapterIds);
void RefreshMetadata(int libraryId, bool forceUpdate = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false);
void RefreshMetadata(int libraryId, bool forceUpdate = true, bool forceColorscape = true);
void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false, bool forceColorscape = false);
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false);
void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false);
@ -371,12 +371,12 @@ public class TaskScheduler : ITaskScheduler
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",
[libraryId, true]) ||
[libraryId, true, true]) ||
HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary",
[libraryId, false]);
[libraryId, false, false]);
if (alreadyEnqueued)
{
_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);
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");
return;
}
_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)

View File

@ -221,7 +221,7 @@ public class ScannerService : IScannerService
var libraryPaths = library.Folders.Select(f => f.Path).ToList();
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));
return;
}

View File

@ -16,9 +16,8 @@
font-size: 0.8rem;
}
.btn {
//padding: 4px 8px !important;
//font-size: 0.8rem !important;
.main-container {
overflow: unset !important;
}
.btn-group > .btn.dropdown-toggle-split:not(first-child){
@ -112,6 +111,7 @@
-webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar;
scrollbar-width: none;
box-shadow: inset -1px -2px 0px -1px var(--elevation-layer9);
}
.carousel-tabs-container::-webkit-scrollbar {
display: none;
@ -119,3 +119,92 @@
.nav-tabs {
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 callback Optional callback to perform actions after API completes
* @param forceUpdate Optional Should we force
* @param forceColorscape Optional Should we force colorscape gen
* @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) {
return;
}
@ -110,7 +111,7 @@ export class ActionService {
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}));
if (callback) {
@ -236,8 +237,9 @@ export class ActionService {
* @param series Series, must have libraryId, id and name populated
* @param callback Optional callback to perform actions after API completes
* @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
if (forceUpdate) {
@ -251,7 +253,7 @@ export class ActionService {
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}));
if (callback) {
callback(series);

View File

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

View File

@ -1,13 +1,12 @@
import { DOCUMENT } from '@angular/common';
import {DestroyRef, inject, Inject, Injectable, OnDestroy, Renderer2, RendererFactory2} from '@angular/core';
import {filter, ReplaySubject, Subject, take} from 'rxjs';
import {DOCUMENT} from '@angular/common';
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
import {distinctUntilChanged, filter, ReplaySubject, take} from 'rxjs';
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {TextResonse} from "../_types/text-response";
import {DashboardStream} from "../_models/dashboard/dashboard-stream";
import {AccountService} from "./account.service";
import {map, tap} from "rxjs/operators";
import {map} from "rxjs/operators";
import {NavigationEnd, Router} from "@angular/router";
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.
*/
showNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', 'var(--nav-offset)');
this.renderer.removeStyle(this.document.querySelector('body'), 'scrollbar-gutter');
this.renderer.setStyle(this.document.querySelector('body'), '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);
setTimeout(() => {
const bodyElem = this.document.querySelector('body');
this.renderer.setStyle(bodyElem, 'margin-top', 'var(--nav-offset)');
this.renderer.removeStyle(bodyElem, 'scrollbar-gutter');
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.
*/
hideNavBar() {
this.renderer.setStyle(this.document.querySelector('body'), 'margin-top', '0px');
this.renderer.setStyle(this.document.querySelector('body'), 'scrollbar-gutter', 'initial');
this.renderer.removeStyle(this.document.querySelector('body'), 'height');
this.renderer.removeStyle(this.document.querySelector('html'), 'height');
this.navbarVisibleSource.next(false);
setTimeout(() => {
const bodyElem = this.document.querySelector('body');
this.renderer.removeStyle(bodyElem, 'height');
this.renderer.removeStyle(this.document.querySelector('html'), 'height');
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) {
this.sideNavCollapseSource.next(state);
localStorage.setItem(this.localStorageSideNavKey, state + '');
collapseSideNav(isCollapsed: boolean) {
this.sideNavCollapseSource.next(isCollapsed);
localStorage.setItem(this.localStorageSideNavKey, isCollapsed + '');
}
}

View File

@ -375,7 +375,7 @@ export class ReaderService {
}
// 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) {

View File

@ -143,8 +143,8 @@ export class SeriesService {
}
refreshMetadata(series: Series, force = true) {
return this.httpClient.post(this.baseUrl + 'series/refresh-metadata', {libraryId: series.libraryId, seriesId: series.id, forceUpdate: force});
refreshMetadata(series: Series, force = true, forceColorscape = true) {
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) {

View File

@ -1,5 +1,5 @@
<ng-container *transloco="let t; read: 'details-tab'">
<div class="details pb-3">
<div class="mb-3">
<app-carousel-reel [items]="genres" [title]="t('genres-title')">
<ng-template #carouselItem let-item>
@ -132,4 +132,5 @@
</ng-template>
</app-carousel-reel>
</div>
</div>
</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 {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
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 {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
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 {ReadTimePipe} from "../../_pipes/read-time.pipe";
import {ChapterService} from "../../_services/chapter.service";
import {AgeRating} from "../../_models/metadata/age-rating";
enum TabID {
General = 'general-tab',
@ -100,7 +100,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
CoverImageChooserComponent,
EditChapterProgressComponent,
NgbInputDatepicker,
EntityInfoCardsComponent,
CompactNumberPipe,
IconAndTitleComponent,
DefaultDatePipe,
@ -120,7 +119,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
export class EditChapterModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal);
private readonly seriesService = inject(SeriesService);
public readonly utilityService = inject(UtilityService);
public readonly imageService = inject(ImageService);
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('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('isbn', new FormControl(this.chapter.isbn, []));
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;
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 = [

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 {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.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 {IconAndTitleComponent} from "../../shared/icon-and-title/icon-and-title.component";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
@ -83,7 +82,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
CoverImageChooserComponent,
EditChapterProgressComponent,
NgbInputDatepicker,
EntityInfoCardsComponent,
CompactNumberPipe,
IconAndTitleComponent,
DefaultDatePipe,

View File

@ -1,15 +1,15 @@
<ng-container *transloco="let t; read:'review-card'">
<div class="card review-card clickable mb-3" (click)="showModal()">
<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) {
<i class="d-md-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="">
<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:true" width="40" height="40" alt="">
} @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 class="col-md-10">
<div class="col-md-10 col-sm-10 col-10">
<div class="card-body p-2">
<!--
<h6 class="card-title">

View File

@ -2,22 +2,25 @@
<div class="offcanvas-header">
<h5 class="offcanvas-title">
{{name}}
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('common.close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
<ng-container *ngIf="CoverUrl as coverUrl">
@if (CoverUrl; as coverUrl) {
<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>
</ng-container>
}
<ng-container *ngIf="externalSeries; else localSeriesBody">
<div *ngIf="(externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0" class="text-muted muted mb-2">
{{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}}
</div>
@if (externalSeries) {
@if ((externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0) {
<div class="text-muted muted mb-2">
{{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}}
</div>
}
@if(isExternalSeries && externalSeries) {
<div class="text-muted muted mb-2">
@ -26,14 +29,20 @@
</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">
<app-metadata-detail [tags]="externalSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item}}
</app-tag-badge>
<span class="dark-exempt btn-icon not-clickable">{{item}}</span>
</ng-template>
</app-metadata-detail>
</div>
@ -41,25 +50,22 @@
<div class="mt-3">
<app-metadata-detail [tags]="externalSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item.name}}
</app-tag-badge>
<span class="dark-exempt btn-icon not-clickable">{{item.name}}</span>
</ng-template>
</app-metadata-detail>
</div>
<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>
<div class="card mb-3">
<div class="row g-0">
<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>
</ng-container>
<ng-template #localPerson>
} @else {
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
</ng-template>
}
</div>
<div class="col-md-9">
<div class="card-body">
@ -72,67 +78,56 @@
</ng-template>
</app-metadata-detail>
</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>
<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>
<app-read-more [maxLength]="300" [text]="localSeries.summary"></app-read-more>
<div class="mt-3">
<app-metadata-detail [tags]="localSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item.title}}
</app-tag-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item>
<a class="dark-exempt btn-icon not-clickable">{{item.title}}</a>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item.title}}
</app-tag-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item>
<span class="dark-exempt btn-icon not-clickable">{{item.title}}</span>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
<ng-template #itemTemplate let-item>
<div class="card mb-3">
<div class="row g-0">
<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>
</div>
<div class="col-md-8">
<div class="card-body">
<h6 class="card-title">{{item.name}}</h6>
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')" [includeComma]="false">
<ng-template #itemTemplate let-item>
<div class="card mb-3">
<div class="row g-0">
<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>
</div>
<div class="col-md-8">
<div class="card-body">
<h6 class="card-title">{{item.name}}</h6>
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
</div>
</div>
</div>
</ng-template>
</app-metadata-detail>
</div>
</ng-container>
</ng-template>
</div>
</ng-template>
</app-metadata-detail>
</div>
}
<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>
</ng-container>

View File

@ -15,4 +15,13 @@
a.read-more-link {
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 {CommonModule, NgOptimizedImage} from '@angular/common';
import {NgOptimizedImage} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {NgbActiveOffcanvas, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
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 {ActionService} from "../../_services/action.service";
import {ProviderImagePipe} from "../../_pipes/provider-image.pipe";
import {ScrobbleProvider} from "../../_services/scrobbling.service";
import {FilterField} from "../../_models/metadata/v2/filter-field";
@Component({
selector: 'app-series-preview-drawer',
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',
styleUrls: ['./series-preview-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
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() aniListId?: number;
@Input() malId?: number;
@ -42,11 +51,7 @@ export class SeriesPreviewDrawerComponent implements OnInit {
url: string = '';
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() {
if (this.isExternalSeries) {

View File

@ -1,4 +1,24 @@
<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) {
<app-carousel-reel [items]="readingLists" [title]="t('reading-lists-title')">
<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 {ImageService} from "../../_services/image.service";
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({
selector: 'app-related-tab',
@ -11,7 +21,8 @@ import {TranslocoDirective} from "@jsverse/transloco";
imports: [
CardItemComponent,
CarouselReelComponent,
TranslocoDirective
TranslocoDirective,
SeriesCardComponent
],
templateUrl: './related-tab.component.html',
styleUrl: './related-tab.component.scss',
@ -20,11 +31,18 @@ import {TranslocoDirective} from "@jsverse/transloco";
export class RelatedTabComponent {
protected readonly imageService = inject(ImageService);
protected readonly router = inject(Router);
@Input() readingLists: Array<ReadingList> = [];
@Input() collections: Array<UserCollection> = [];
@Input() relations: Array<RelatedSeriesPair> = [];
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">
<input id="settings-hostname" aria-describedby="hostname-validations" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="formControl.invalid && formControl.touched">
<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)="autofillGmail()">{{t('gmail-label')}}</button>
<button type="button" class="btn btn-outline-secondary" (click)="autofillOutlook()">{{t('outlook-label')}}</button>
</div>
@if(settingsForm.dirty || settingsForm.touched) {
@ -39,7 +39,7 @@
{{formControl.value | defaultValue}}
</ng-template>
<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>
</app-setting-item>
}
@ -52,7 +52,7 @@
{{formControl.value | defaultValue}}
</ng-template>
<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>
</app-setting-item>
}
@ -78,7 +78,7 @@
{{formControl.value | defaultValue}}
</ng-template>
<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>
</app-setting-item>
}
@ -89,7 +89,7 @@
<app-setting-switch [title]="t('enable-ssl-label')">
<ng-template #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>
</ng-template>
</app-setting-switch>
@ -103,7 +103,7 @@
{{formControl.value | defaultValue}}
</ng-template>
<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>
</app-setting-item>
}
@ -116,7 +116,7 @@
{{formControl.value ? '********' : null | defaultValue}}
</ng-template>
<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>
</app-setting-item>
}
@ -129,7 +129,7 @@
{{formControl.value | bytes}}
</ng-template>
<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>
</app-setting-item>
}

View File

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

View File

@ -39,7 +39,7 @@
<div class="input-group">
<input id="settings-baseurl" aria-describedby="settings-baseurl-help" class="form-control" formControlName="baseUrl" type="text"
[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>
@if(settingsForm.dirty || settingsForm.touched) {
@ -64,7 +64,7 @@
<div class="input-group">
<input id="settings-ipaddresses" aria-describedby="settings-ipaddresses-help" class="form-control" formControlName="ipAddresses" type="text"
[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>
@if(settingsForm.dirty || settingsForm.touched) {

View File

@ -1,31 +1,59 @@
.content-wrapper {
padding: 0 10px 0;
height: 100%;
height: calc(var(--vh)* 100 - var(--nav-offset));
}
.companion-bar {
transition: all var(--side-nav-companion-bar-transistion);
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 {
margin-left: 0 !important;
}
.companion-bar-content {
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 {
padding: 0;
}
.content-wrapper {
padding: 0 5px 0;
overflow: hidden;
height: calc(var(--vh)*100 - var(--nav-offset));
height: calc(var(--vh)* 100 - var(--nav-mobile-offset));
padding: 0 10px 0;
&.closed {
overflow: auto;
@ -35,11 +63,27 @@
.companion-bar {
margin-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 {
margin-left: 0;
width: auto;
}
}
@ -67,7 +111,7 @@
height: 100vh;
z-index: -1;
pointer-events: none;
background-color: #121212;
background-color: var(--bs-body-bg);
filter: blur(20px);
object-fit: contain;
transform: scale(1.1);
@ -80,4 +124,3 @@
height: 113vh;
}
}

View File

@ -4,7 +4,6 @@ import {
DestroyRef,
HostListener,
inject,
Inject,
OnInit
} from '@angular/core';
import {NavigationStart, Router, RouterOutlet} from '@angular/router';
@ -46,11 +45,12 @@ export class AppComponent implements OnInit {
private readonly ngbModal = inject(NgbModal);
private readonly router = inject(Router);
private readonly themeService = inject(ThemeService);
private readonly document = inject(DOCUMENT);
protected readonly Breakpoint = Breakpoint;
constructor(ratingConfig: NgbRatingConfig, @Inject(DOCUMENT) private document: Document, modalConfig: NgbModalConfig) {
constructor(ratingConfig: NgbRatingConfig, modalConfig: NgbModalConfig) {
modalConfig.fullscreen = 'md';
@ -80,7 +80,6 @@ export class AppComponent implements OnInit {
const currentRoute = this.router.routerState;
await this.router.navigateByUrl(currentRoute.snapshot.url, { skipLocationChange: true });
}
});
@ -106,6 +105,7 @@ export class AppComponent implements OnInit {
this.themeService.setColorScape('');
}
setCurrentUser() {
const user = this.accountService.getUserFromLocalStorage();
this.accountService.setCurrentUser(user);
@ -114,8 +114,6 @@ export class AppComponent implements OnInit {
// Bootstrap anything that's needed
this.themeService.getThemes().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
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);
break;
case Action.GenerateColorScape:
await this.actionService.refreshSeriesMetadata(this.series, undefined, false);
await this.actionService.refreshSeriesMetadata(this.series, undefined, false, true);
break;
case Action.AnalyzeFiles:
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-x: hidden;
align-items: start;
}
@media (max-width: 576px) {
@ -92,13 +94,33 @@
.virtual-scroller, virtual-scroller {
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 {
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 {
display: inline-block;
word-break: break-all;

View File

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

View File

@ -134,13 +134,17 @@ export class CardItemComponent implements OnInit {
*/
@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
*/
@Input() centerOverlay = false;
/**
* Will generate a button to instantly read
*/
@Input() hasReadButton = false;
/**
* Event emitted when item is clicked
*/
@ -149,6 +153,7 @@ export class CardItemComponent implements OnInit {
* When the card is selected.
*/
@Output() selection = new EventEmitter<boolean>();
@Output() readClicked = new EventEmitter<Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter>();
@ContentChild('subtitle') subtitleTemplate!: TemplateRef<any>;
/**
* Library name item belongs to
@ -229,9 +234,10 @@ export class CardItemComponent implements OnInit {
const nextDate = (this.entity as NextExpectedChapter);
const tokens = nextDate.title.split(':');
this.overlayInformation = `
<i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i>
<div>${tokens[0]}</div><div>${tokens[1]}</div>`;
// this.overlayInformation = `
// <i class="fa-regular fa-clock mb-2" style="font-size: 26px" aria-hidden="true"></i>
// <div>${tokens[0]}</div><div>${tokens[1]}</div>`;
// // todo: figure out where this caller is
this.centerOverlay = true;
if (nextDate.expectedDate) {
@ -387,4 +393,11 @@ export class CardItemComponent implements OnInit {
// 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) {
@case (LibraryType.Comic) {
@if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('issue-num') + ' ' + number + ' - ' }}
}
{{titleName}}
} @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
}
@ -14,9 +17,12 @@
@case (LibraryType.ComicVine) {
@if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('issue-num') + ' ' + number + ' - ' }}
}
{{titleName}}
} @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
}
@ -26,12 +32,15 @@
@case (LibraryType.Manga) {
@if (titleName !== '' && prioritizeTitleName) {
@if (isChapter && includeChapter) {
{{t('chapter') + ' ' + number + ' - ' }}
}
{{titleName}}
} @else {
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
@if (includeVolume && volumeTitle !== '') {
{{number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
}
{{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
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
@Input() seriesName: string = '';
@Input({required: true}) entity!: Volume | Chapter;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@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
*/

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

View File

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

View File

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

View File

@ -39,6 +39,7 @@ import {BulkSelectionService} from "../bulk-selection.service";
import {User} from "../../_models/user";
import {ScrollService} from "../../_services/scroll.service";
import {ReaderService} from "../../_services/reader.service";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
function deepClone(obj: any): any {
if (obj === null || typeof obj !== 'object') {
@ -67,7 +68,7 @@ function deepClone(obj: any): any {
@Component({
selector: 'app-series-card',
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',
styleUrls: ['./series-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -284,7 +285,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
}
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) {

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) {
<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 (chapter.pagesRead < chapter.pages && chapter.pagesRead > 0) {
<div class="progress-banner" ngbTooltip="{{(chapter.pagesRead / chapter.pages) * 100 | number:'1.0-1'}}%">
@if(mobileSeriesImgBackground === 'true') {
<app-image [styles]="{'background': 'none'}" [imageUrl]="coverImage"></app-image>
} @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>
</div>
}
@ -25,25 +30,22 @@
</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">
<a routerLink="/library/{{series.libraryId}}/series/{{series.id}}" class="dark-exempt btn-icon">{{series.name}}</a>
</h4>
<div class="subtitle mt-2 mb-2">
<span>
<app-entity-title [libraryType]="libraryType!" [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title>
<span class="me-2">
<app-entity-title [libraryType]="libraryType" [entity]="chapter" [prioritizeTitleName]="true" [includeChapter]="true"></app-entity-title>
</span>
@if (chapter.titleName) {
<span class="ms-2 me-2"></span>
<span>{{chapter.titleName}}</span>
}
</div>
<app-metadata-detail-row [entity]="chapter"
[ageRating]="chapter.ageRating"
[hasReadingProgress]="chapter.pagesRead > 0"
[readingTimeEntity]="chapter"
[libraryType]="libraryType">
[libraryType]="libraryType"
[mangaFormat]="series.format">
</app-metadata-detail-row>
@ -92,6 +94,12 @@
</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">
<app-download-button [download$]="download$" [entity]="chapter" entityType="chapter"></app-download-button>
</div>
@ -108,7 +116,7 @@
<div class="col-6">
<span class="fw-bold">{{t('writers-title')}}</span>
<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">
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
</ng-template>
@ -116,11 +124,50 @@
</div>
</div>
<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>
<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">
<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>
</app-badge-expander>
</div>
@ -131,16 +178,8 @@
</div>
</div>
<!-- <app-carousel-tabs [(activeTabId)]="activeTabId">-->
<!-- <app-carousel-tab [id]="TabId.Details">-->
<!-- @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)">
<div class="carousel-tabs-container mb-2">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs" (navChange)="onNavChange($event)">
@if (showDetailsTab) {
<li [ngbNavItem]="TabID.Details">

View File

@ -9,10 +9,9 @@ import {
} from '@angular/core';
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.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 {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 {ImageComponent} from "../shared/image/image.component";
import {LoadingComponent} from "../shared/loading/loading.component";
@ -73,9 +72,16 @@ import {
} from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component";
import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component";
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 {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 {
Related = 'related-tab',
@ -86,53 +92,55 @@ enum TabID {
@Component({
selector: 'app-chapter-detail',
standalone: true,
imports: [
BulkOperationsComponent,
AsyncPipe,
CardActionablesComponent,
CarouselReelComponent,
DecimalPipe,
ExternalListItemComponent,
ExternalSeriesCardComponent,
ImageComponent,
LoadingComponent,
NgbDropdown,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle,
NgbNav,
NgbNavContent,
NgbNavLink,
NgbProgressbar,
NgbTooltip,
PersonBadgeComponent,
ReviewCardComponent,
SeriesCardComponent,
TagBadgeComponent,
VirtualScrollerModule,
NgStyle,
AgeRatingPipe,
TimeDurationPipe,
ExternalRatingComponent,
TranslocoDirective,
ReadMoreComponent,
NgbNavItem,
NgbNavOutlet,
DetailsTabComponent,
RouterLink,
EntityTitleComponent,
ReadTimePipe,
DefaultValuePipe,
CardItemComponent,
RelatedTabComponent,
AgeRatingImageComponent,
CompactNumberPipe,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
CarouselTabComponent,
CarouselTabsComponent
],
imports: [
BulkOperationsComponent,
AsyncPipe,
CardActionablesComponent,
CarouselReelComponent,
DecimalPipe,
ExternalSeriesCardComponent,
ImageComponent,
LoadingComponent,
NgbDropdown,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle,
NgbNav,
NgbNavContent,
NgbNavLink,
NgbProgressbar,
NgbTooltip,
PersonBadgeComponent,
ReviewCardComponent,
SeriesCardComponent,
TagBadgeComponent,
VirtualScrollerModule,
NgStyle,
NgClass,
AgeRatingPipe,
TimeDurationPipe,
ExternalRatingComponent,
TranslocoDirective,
ReadMoreComponent,
NgbNavItem,
NgbNavOutlet,
DetailsTabComponent,
RouterLink,
EntityTitleComponent,
ReadTimePipe,
DefaultValuePipe,
CardItemComponent,
RelatedTabComponent,
AgeRatingImageComponent,
CompactNumberPipe,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
PublicationStatusPipe,
DatePipe,
DefaultDatePipe,
MangaFormatPipe
],
templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@ -158,11 +166,14 @@ export class ChapterDetailComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly readingListService = inject(ReadingListService);
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 TabID = TabID;
protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -184,7 +195,8 @@ export class ChapterDetailComponent implements OnInit {
downloadInProgress: boolean = false;
readingLists: ReadingList[] = [];
showDetailsTab: boolean = true;
mobileSeriesImgBackground: string | undefined;
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
get ScrollingBlockHeight() {
@ -208,13 +220,28 @@ export class ChapterDetailComponent implements OnInit {
return;
}
this.mobileSeriesImgBackground = getComputedStyle(document.documentElement)
.getPropertyValue('--mobile-series-img-background').trim();
this.seriesId = parseInt(seriesId, 10);
this.chapterId = parseInt(chapterId, 10);
this.libraryId = parseInt(libraryId, 10);
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({
series: this.seriesService.getSeries(this.seriesId),
@ -303,7 +330,7 @@ export class ChapterDetailComponent implements OnInit {
updateUrl(activeTab: TabID) {
const newUrl = `${this.router.url.split('#')[0]}#${activeTab}`;
//this.router.navigateByUrl(newUrl, { onSameUrlNavigation: 'ignore' });
window.history.replaceState({}, '', newUrl);
}
openPerson(field: FilterField, value: number) {
@ -311,12 +338,61 @@ export class ChapterDetailComponent implements OnInit {
}
downloadChapter() {
if (this.downloadInProgress) return;
this.downloadService.download('chapter', this.chapter!, (d) => {
this.downloadInProgress = !!d;
this.cdRef.markForCheck();
});
}
protected readonly TabId = TabId;
protected readonly Breakpoint = Breakpoint;
openFilter(field: FilterField, value: string | number) {
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) {
<div class="under-image">
<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>
<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>

View File

@ -66,9 +66,20 @@
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick(StreamId.RecentlyUpdatedSeries)">
<ng-template #carouselItem let-item>
<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>
</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>

View File

@ -34,6 +34,7 @@ import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.se
import {ToastrService} from "ngx-toastr";
import {ServerService} from "../../_services/server.service";
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
import {ReaderService} from "../../_services/reader.service";
enum StreamId {
OnDeck,
@ -69,7 +70,7 @@ export class DashboardComponent implements OnInit {
private readonly dashboardService = inject(DashboardService);
private readonly scrobblingService = inject(ScrobblingService);
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))
isLoadingDashboard = true;
@ -203,6 +204,13 @@ export class DashboardComponent implements OnInit {
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) {
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>
@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">
<app-image width="28px" height="28px" imageUrl="assets/images/logo-32.png" classes="logo" />
<span class="d-none d-md-inline logo"> Kavita</span>

View File

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

View File

@ -7,7 +7,7 @@
</div>
<div class="modal-body">
<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 class="mb-3">
<a routerLink="/all-filters/">{{t('all-filters')}}</a>

View File

@ -1,7 +1,7 @@
import {Component, inject, Input} from '@angular/core';
import {WikiLink} from "../../../_models/wiki";
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 {ReactiveFormsModule} from "@angular/forms";
import {Select2Module} from "ng-select2-component";
@ -27,8 +27,10 @@ export class NavLinkModalComponent {
@Input({required: true}) logoutFn!: () => void;
private readonly modal = inject(NgbActiveModal);
private readonly router = inject(Router);
protected readonly WikiLink = WikiLink;
protected readonly SettingsTabId = SettingsTabId;
close() {
this.modal.close();
@ -38,5 +40,14 @@ export class NavLinkModalComponent {
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'">
<div class="{{theme}}" *ngIf="accountService.currentUser$ | async as user" #container>
@if (accountService.currentUser$ | async; as user) {
<div class="{{theme}}" #container>
<ng-container *ngIf="isLoading">
<div class="loading mx-auto" style="min-width: 200px; width: 600px;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{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>
@if (isLoading) {
<div class="loading mx-auto" style="min-width: 200px; width: 600px;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
{{t('loading-message')}}
</div>
</div>
</ng-container>
<ngx-extended-pdf-viewer
#pdfViewer
[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"
<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>
}
[(scrollMode)]="scrollMode"
[pageViewMode]="pageLayoutMode"
[spread]="spreadMode"
<ngx-extended-pdf-viewer
#pdfViewer
[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()"
(pdfLoadingStarts)="updateLoading(true)"
(pdfLoaded)="updateLoading(false)"
(progress)="updateLoadProgress($event)"
(zoomChange)="calcScrollbarNeeded()"
(handToolChange)="updateHandTool($event)"
>
[(scrollMode)]="scrollMode"
[pageViewMode]="pageLayoutMode"
[spread]="spreadMode"
</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) {
<div class="left" (click)="prevPage()"></div>
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
}
</ngx-extended-pdf-viewer>
<ng-template #multiToolbar>
<div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}">
<div id="toolbarViewerLeft">
<pdf-toggle-sidebar></pdf-toggle-sidebar>
<pdf-find-button [textLayer]='true'></pdf-find-button>
<pdf-paging-area></pdf-paging-area>
@if (scrollMode === ScrollModeType.page && !isLoading) {
<div class="left" (click)="prevPage()"></div>
<div class="{{scrollbarNeeded ? 'right-with-scrollbar' : 'right'}}" (click)="nextPage()"></div>
}
@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>
<ng-template #multiToolbar>
<div style="min-height: 36px" id="toolbarViewer" [ngStyle]="{'background-color': backgroundColor, 'color': fontColor}">
<div id="toolbarViewerLeft">
<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>
}
</div>
<button *ngIf="incognitoMode" [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>
<pdf-zoom-toolbar ></pdf-zoom-toolbar>
<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>
<pdf-zoom-toolbar ></pdf-zoom-toolbar>
<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-template>
</div>
}
</ng-container>

View File

@ -13,7 +13,7 @@ import {
import {ActivatedRoute, Router} from '@angular/router';
import {NgxExtendedPdfViewerModule, PageViewModeType, ProgressBarEvent, ScrollModeType} from 'ngx-extended-pdf-viewer';
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 {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
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 {ThemeService} from 'src/app/_services/theme.service';
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 {PdfLayoutMode} from "../../../_models/preferences/pdf-layout-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'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, NgStyle, NgxExtendedPdfViewerModule, NgbTooltip, AsyncPipe, TranslocoDirective,
imports: [NgStyle, NgxExtendedPdfViewerModule, NgbTooltip, AsyncPipe, TranslocoDirective,
PdfLayoutModePipe, PdfScrollModeTypePipe, PdfSpreadTypePipe]
})
export class PdfReaderComponent implements OnInit, OnDestroy {

View File

@ -1,17 +1,18 @@
<ng-container *transloco="let t; read: 'shortcuts-modal'">
<div class="modal-header">
<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 class="modal-body">
<div class="row g-0">
<div class="col-md-6 mb-2" *ngFor="let shortcut of shortcuts">
<span><code>{{shortcut.key}}</code> {{t(shortcut.description)}}</span>
</div>
@for(shortcut of shortcuts; track shortcut.key) {
<div class="col-md-6 mb-2">
<span><code>{{shortcut.key}}</code> {{t(shortcut.description)}}</span>
</div>
}
</div>
</div>
<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>
</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 {CommonModule} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco";
export interface KeyboardShortcut {
@ -17,18 +16,14 @@ export interface KeyboardShortcut {
@Component({
selector: 'app-shortcuts-modal',
standalone: true,
imports: [CommonModule, NgbModalModule, TranslocoDirective],
imports: [NgbModalModule, TranslocoDirective],
templateUrl: './shortcuts-modal.component.html',
styleUrls: ['./shortcuts-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ShortcutsModalComponent {
protected readonly modal = inject(NgbActiveModal);
@Input() shortcuts: Array<KeyboardShortcut> = [];
constructor(public modal: NgbActiveModal) { }
close() {
this.modal.close();
}
}

View File

@ -5,7 +5,9 @@
@if (readingList?.promoted) {
<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>
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h5>
@ -90,50 +92,66 @@
</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>
</div>
@if (readingList.startingYear !== 0) {
<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-->
<div class="row g-0 mt-2">
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
</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>
<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>
<ng-container *ngIf="items.length === 0 && !isLoading; else loading">
@if (items.length === 0 && !isLoading) {
<div class="mx-auto" style="width: 200px;">
{{t('no-data')}}
</div>
</ng-container>
<ng-template #loading>
<app-loading *ngIf="isLoading" [loading]="isLoading"></app-loading>
</ng-template>
} @else if(isLoading) {
<app-loading [loading]="isLoading"></app-loading>
}
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<ng-container *transloco="let t; read: '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>
@if (item.pagesRead === 0 && item.pagesTotal > 0) {
<div class="not-read-badge" ></div>
@ -16,18 +16,18 @@
<div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{item.title}}
<div class="float-end">
<div class="actions float-end">
<button class="btn btn-danger" (click)="remove.emit(item)">
<span>
<i class="fa fa-trash me-1" aria-hidden="true"></i>
</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 class="btn btn-primary ms-2" (click)="readChapter(item)">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-sm-inline-block">{{t('read')}}</span>
<span class="d-none d-md-inline-block">{{t('read')}}</span>
</button>
</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-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'">
<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" />
@if (hasUserRated) {
{{userRating * 20}}
@ -16,45 +54,23 @@
@if (hasUserRated || overallRating > 0) {
%
}
</span>
</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">
@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>
</span>
</ng-template>
<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>
{{userRating * 20}}%
</ng-template>
<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) {
<a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a>
}
</ng-template>
</ng-container>

View File

@ -7,11 +7,10 @@ import {
OnInit,
ViewEncapsulation
} from '@angular/core';
import {CommonModule, NgOptimizedImage} from '@angular/common';
import {SeriesService} from "../../../_services/series.service";
import {Rating} from "../../../_models/rating";
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 {LibraryType} from "../../../_models/library/library";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
@ -22,11 +21,14 @@ import {ImageComponent} from "../../../shared/image/image.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
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({
selector: 'app-external-rating',
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',
styleUrls: ['./external-rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@ -40,6 +42,7 @@ export class ExternalRatingComponent implements OnInit {
public readonly utilityService = inject(UtilityService);
public readonly destroyRef = inject(DestroyRef);
public readonly imageService = inject(ImageService);
public readonly modalService = inject(NgbModal);
protected readonly Breakpoint = Breakpoint;
@ -65,4 +68,17 @@ export class ExternalRatingComponent implements OnInit {
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>
</span>
<span class="me-2">
<app-series-format [format]="mangaFormat" [useTitle]="false"></app-series-format>
</span>
@if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) {
<span class="word-count me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
} @else {

View File

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

View File

@ -4,7 +4,7 @@
<h5>{{heading}}</h5>
</div>
<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">
@if(itemTemplate) {
<span (click)="goTo(queryParam, item.id)">

View File

@ -28,6 +28,7 @@ export class MetadataDetailComponent {
@Input({required: true}) libraryId!: number;
@Input({required: true}) heading!: string;
@Input() queryParam: FilterField = FilterField.None;
@Input() includeComma: boolean = true;
@ContentChild('titleTemplate') titleTemplate!: 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) {
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock>
<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">
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="seriesImage"></app-image>
<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]="{'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) {
<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>
@ -29,7 +32,7 @@
</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">
<span>{{series.name}}
@ -52,7 +55,8 @@
[ageRating]="seriesMetadata.ageRating"
[hasReadingProgress]="hasReadingProgress"
[readingTimeEntity]="series"
[libraryType]="libraryType">
[libraryType]="libraryType"
[mangaFormat]="series.format">
</app-metadata-detail-row>
<!-- Rating goes here (after I implement support for rating individual issues -->
@ -133,12 +137,13 @@
<div class="mt-2 upper-details">
<div class="row g-0">
<div class="col-6">
<div class="col-6 pe-5">
<span class="fw-bold">{{t('writers-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.writers"
[itemsTillExpander]="3"
[allowToggle]="false">
[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.Writers, item.id)">{{item.name}}</a>
</ng-template>
@ -162,12 +167,13 @@
<div class="mt-3 mb-2 upper-details">
<div class="row g-0">
<div class="col-6">
<div class="col-6 pe-5">
<span class="fw-bold">{{t('genres-title')}}</span>
<div>
<app-badge-expander [items]="seriesMetadata.genres"
[itemsTillExpander]="3"
[allowToggle]="false">
[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.Genres, item.id)">{{item.title}}</a>
</ng-template>
@ -180,7 +186,8 @@
<div>
<app-badge-expander [items]="seriesMetadata.tags"
[itemsTillExpander]="3"
[allowToggle]="false">
[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>
@ -189,45 +196,11 @@
</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 class="carousel-tabs-container">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
<div class="carousel-tabs-container mb-2">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs" [destroyOnHide]="false" (navChange)="onNavChange($event)">
@if (showStorylineTab) {
<li [ngbNavItem]="TabID.Storyline">
@ -316,138 +289,16 @@
</li>
}
@if (hasRelations && relationShips) {
@if (hasRelations || readingLists.length > 0 || collections.length > 0) {
<li [ngbNavItem]="TabID.Related">
<a ngbNavLink>
{{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>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Related; prefetch on idle) {
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
<div class="card-container row g-0" #container>
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
<app-series-card class="col-auto mt-2 mb-2" [series]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
}
</div>
</virtual-scroller>
<app-related-tab [readingLists]="readingLists" [collections]="collections" [relations]="relations"></app-related-tab>
}
<!-- @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>
</li>
}

View File

@ -7,19 +7,7 @@
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{
display: grid;
grid-template-columns: repeat(auto-fill, 160px);
@ -27,7 +15,7 @@
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-style: solid;
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';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
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 {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter";
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 {hasAnyCast} from "../../../_models/common/i-has-cast";
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 {
Related = 'related-tab',
@ -176,12 +174,12 @@ interface StoryLineItem {
TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
NgbDropdownItem, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent,
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, CardItemComponent,
EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe, PersonBadgeComponent, DetailsTabComponent, ChapterCardComponent,
VolumeCardComponent, JsonPipe, AgeRatingPipe, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, ReadTimePipe,
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 {
@ -200,10 +198,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService);
private readonly readingListService = inject(ReadingListService);
private readonly offcanvasService = inject(NgbOffcanvas);
private readonly collectionTagService = inject(CollectionTagService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly scrollService = inject(ScrollService);
private readonly deviceService = inject(DeviceService);
private readonly translocoService = inject(TranslocoService);
protected readonly bulkSelectionService = inject(BulkSelectionService);
protected readonly utilityService = inject(UtilityService);
@ -243,6 +240,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
isLoadingExtra = false;
libraryAllowsScrobbling = false;
isScrobbling: boolean = true;
mobileSeriesImgBackground: string | undefined;
currentlyReadingChapter: Chapter | undefined = undefined;
hasReadingProgress = false;
@ -262,6 +260,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
libraryType: LibraryType = LibraryType.Manga;
seriesMetadata: SeriesMetadata | null = null;
readingLists: Array<ReadingList> = [];
collections: Array<UserCollection> = [];
isWantToRead: boolean = false;
unreadCount: number = 0;
totalCount: number = 0;
@ -384,6 +383,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}
}
get UseBookLogic() {
return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel;
}
@ -472,6 +472,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return;
}
this.mobileSeriesImgBackground = getComputedStyle(document.documentElement)
.getPropertyValue('--mobile-series-img-background').trim();
// Set up the download in progress
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');
}
} else if (event.event === EVENTS.ScanSeries) {
const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent;
if (seriesCoverUpdatedEvent.seriesId === this.seriesId) {
const seriesScanEvent = event.payload as ScanSeriesEvent;
if (seriesScanEvent.seriesId === this.seriesId) {
this.loadSeries(this.seriesId);
}
} 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) {
const removedEvent = event.payload as ChapterRemovedEvent;
if (removedEvent.seriesId !== this.seriesId) return;
@ -554,13 +560,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
updateUrl(activeTab: TabID) {
var tokens = this.router.url.split('#');
const newUrl = `${tokens[0]}#${activeTab}`;
// if (tokens.length === 1 || tokens[1] === activeTab + '') {
// return;
// }
console.log('url:', newUrl);
//this.router.navigateByUrl(newUrl, { skipLocationChange: true, replaceUrl: true });
window.history.replaceState({}, '', newUrl);
}
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
@ -580,10 +580,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.actionService.scanSeries(series);
break;
case(Action.RefreshMetadata):
this.actionService.refreshSeriesMetadata(series, undefined, true);
this.actionService.refreshSeriesMetadata(series, undefined, true, false);
break;
case(Action.GenerateColorScape):
this.actionService.refreshSeriesMetadata(series, undefined, false);
this.actionService.refreshSeriesMetadata(series, undefined, false, true);
break;
case(Action.Delete):
this.deleteSeries(series);
@ -673,13 +673,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.openChapter(chapter, true);
break;
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.deviceService.sendTo([chapter.id], device.id).subscribe(() => {
this.toastr.success(this.translocoService.translate('series-detail.send-to', {deviceName: device.name}));
});
break;
}
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([chapter.id], device);
break;
default:
break;
}
@ -726,6 +722,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck();
});
this.collectionTagService.allCollectionsForSeries(seriesId, false).subscribe(tags => {
this.collections = tags;
this.cdRef.markForCheck();
})
this.readerService.getTimeLeft(seriesId).subscribe((timeLeft) => {
this.readingTimeLeft = timeLeft;
this.cdRef.markForCheck();
@ -1147,23 +1148,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
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) {
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;">
<div class="container-fluid">
<ng-content></ng-content>
@if (subtitle) {
<div class="description text-muted" [innerHTML]="subtitle | safeHtml"></div>
}

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t;">
<div class="container-fluid">
<div class="row g-0">
<div class="col-11">
<div class="col-10">
<h6 class="section-title">
@if(labelId) {
<label class="reset-label" [for]="labelId">{{title}}</label>
@ -13,9 +13,9 @@
}
</h6>
</div>
<div class="col-1">
<div class="col-2 text-end align-self-end justify-content-end">
@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'))}}
</button>
}

View File

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

View File

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

View File

@ -2,3 +2,9 @@ h2 {
color: white;
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