Lots of Bugfixes (#2960)

Co-authored-by: Samuel Martins <s@smartins.ch>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2024-05-22 06:58:23 -05:00 committed by GitHub
parent 97ffdd0975
commit b50fa0fd1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 563 additions and 282 deletions

View File

@ -21,7 +21,7 @@ jobs:
- name: Install Swashbuckle CLI - name: Install Swashbuckle CLI
shell: powershell shell: powershell
run: dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli run: dotnet tool install -g Swashbuckle.AspNetCore.Cli
- name: Install dependencies - name: Install dependencies
run: dotnet restore run: dotnet restore

View File

@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" /> <PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.2" />

View File

@ -74,7 +74,7 @@ public class WordCountAnalysisTests : AbstractDbTest
var cacheService = new CacheHelper(new FileService()); var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork, var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService); Substitute.For<IEventHub>(), cacheService, _readerService, Substitute.For<IMediaErrorService>());
await service.ScanSeries(1, 1); await service.ScanSeries(1, 1);
@ -126,7 +126,7 @@ public class WordCountAnalysisTests : AbstractDbTest
var cacheService = new CacheHelper(new FileService()); var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork, var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService); Substitute.For<IEventHub>(), cacheService, _readerService, Substitute.For<IMediaErrorService>());
await service.ScanSeries(1, 1); await service.ScanSeries(1, 1);
var chapter2 = new ChapterBuilder("2") var chapter2 = new ChapterBuilder("2")

View File

@ -12,9 +12,9 @@
<LangVersion>latestmajor</LangVersion> <LangVersion>latestmajor</LangVersion>
</PropertyGroup> </PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' "> <!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" /> <!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
</Target> <!-- </Target>-->
<PropertyGroup Condition=" '$(Configuration)' == 'Release' "> <PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>
@ -95,13 +95,13 @@
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" /> <PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
<PackageReference Include="SharpCompress" Version="0.37.2" /> <PackageReference Include="SharpCompress" Version="0.37.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429"> <PackageReference Include="SonarAnalyzer.CSharp" Version="9.25.0.90414">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.1" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.5.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.2" /> <PackageReference Include="System.IO.Abstractions" Version="21.0.2" />
<PackageReference Include="System.Drawing.Common" Version="8.0.4" /> <PackageReference Include="System.Drawing.Common" Version="8.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.1" /> <PackageReference Include="VersOne.Epub" Version="3.3.1" />

View File

@ -0,0 +1,48 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.2 switches Default Kavita installs to WAL
/// </summary>
public static class ManualMigrateSwitchToWal
{
public static async Task Migrate(DataContext context, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateSwitchToWal"))
{
return;
}
logger.LogCritical("Running ManualMigrateSwitchToWal migration - Please be patient, this may take some time. This is not an error");
try
{
var connection = context.Database.GetDbConnection();
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "PRAGMA journal_mode=WAL;";
await command.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Error setting WAL");
/* Swallow */
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateSwitchToWal",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateSwitchToWal migration - Completed. This is not an error");
}
}

View File

@ -0,0 +1,49 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.8.2 introduced Theme repo viewer, this adds Description to existing SiteTheme defaults
/// </summary>
public static class ManualMigrateThemeDescription
{
public static async Task Migrate(DataContext context, ILogger<Program> logger)
{
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateThemeDescription"))
{
return;
}
logger.LogCritical("Running ManualMigrateThemeDescription migration - Please be patient, this may take some time. This is not an error");
var theme = await context.SiteTheme.FirstOrDefaultAsync(t => t.Name == "Dark");
if (theme != null)
{
theme.Description = Seed.DefaultThemes.First().Description;
}
if (context.ChangeTracker.HasChanges())
{
await context.SaveChangesAsync();
}
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
{
Name = "ManualMigrateThemeDescription",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await context.SaveChangesAsync();
logger.LogCritical("Running ManualMigrateThemeDescription migration - Completed. This is not an error");
}
}

View File

@ -1748,12 +1748,12 @@ public class SeriesRepository : ISeriesRepository
{ {
// This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them // This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them
// This here will delete the 2nd one as the first is the one to likely be used. // This here will delete the 2nd one as the first is the one to likely be used.
var sId = _context.Series var sId = await _context.Series
.Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName &&
s.LibraryId == libraryId) s.LibraryId == libraryId)
.Select(s => s.Id) .Select(s => s.Id)
.OrderBy(s => s) .OrderBy(s => s)
.Last(); .LastAsync();
if (sId > 0) if (sId > 0)
{ {
ids.Add(sId); ids.Add(sId);

View File

@ -35,6 +35,7 @@ public static class Seed
Provider = ThemeProvider.System, Provider = ThemeProvider.System,
FileName = "dark.scss", FileName = "dark.scss",
IsDefault = true, IsDefault = true,
Description = "Default theme shipped with Kavita"
} }
}.ToArray() }.ToArray()
]; ];

View File

@ -99,10 +99,13 @@ public class Program
// Apply all migrations on startup // Apply all migrations on startup
logger.LogInformation("Running Migrations"); logger.LogInformation("Running Migrations");
// v0.7.14
try try
{ {
// v0.7.14
await MigrateWantToReadExport.Migrate(context, directoryService, logger); await MigrateWantToReadExport.Migrate(context, directoryService, logger);
// v0.8.2
await ManualMigrateSwitchToWal.Migrate(context, logger);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -73,7 +73,8 @@ public class BookService : IBookService
{ {
PackageReaderOptions = new PackageReaderOptions PackageReaderOptions = new PackageReaderOptions
{ {
IgnoreMissingToc = true IgnoreMissingToc = true,
SkipInvalidManifestItems = true
} }
}; };

View File

@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs.Reader; using API.DTOs.Reader;
@ -51,6 +53,8 @@ public class CacheService : ICacheService
private readonly IReadingItemService _readingItemService; private readonly IReadingItemService _readingItemService;
private readonly IBookmarkService _bookmarkService; private readonly IBookmarkService _bookmarkService;
private static readonly ConcurrentDictionary<int, SemaphoreSlim> ExtractLocks = new();
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork, public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork,
IDirectoryService directoryService, IReadingItemService readingItemService, IDirectoryService directoryService, IReadingItemService readingItemService,
IBookmarkService bookmarkService) IBookmarkService bookmarkService)
@ -166,11 +170,19 @@ public class CacheService : ICacheService
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
var extractPath = GetCachePath(chapterId); var extractPath = GetCachePath(chapterId);
if (_directoryService.Exists(extractPath)) return chapter; SemaphoreSlim extractLock = ExtractLocks.GetOrAdd(chapterId, id => new SemaphoreSlim(1,1));
var files = chapter?.Files.ToList();
ExtractChapterFiles(extractPath, files, extractPdfToImages);
return chapter; await extractLock.WaitAsync();
try {
if(_directoryService.Exists(extractPath)) return chapter;
var files = chapter?.Files.ToList();
ExtractChapterFiles(extractPath, files, extractPdfToImages);
} finally {
extractLock.Release();
}
return chapter;
} }
/// <summary> /// <summary>
@ -191,15 +203,25 @@ public class CacheService : ICacheService
if (files.Count > 0 && files[0].Format == MangaFormat.Image) if (files.Count > 0 && files[0].Format == MangaFormat.Image)
{ {
foreach (var file in files) // Check if all the files are Images. If so, do a directory copy, else do the normal copy
if (files.All(f => f.Format == MangaFormat.Image))
{ {
if (fileCount > 1) _directoryService.ExistOrCreate(extractPath);
{ _directoryService.CopyFilesToDirectory(files.Select(f => f.FilePath), extractPath);
extraPath = file.Id + string.Empty;
}
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), MangaFormat.Image, files.Count);
} }
_directoryService.Flatten(extractDi.FullName); else
{
foreach (var file in files)
{
if (fileCount > 1)
{
extraPath = file.Id + string.Empty;
}
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), MangaFormat.Image, files.Count);
}
_directoryService.Flatten(extractDi.FullName);
}
} }
foreach (var file in files) foreach (var file in files)

View File

@ -439,18 +439,13 @@ public class ImageService : IImageService
rows = 1; rows = 1;
cols = 2; cols = 2;
} }
else if (coverImages.Count == 3)
{
rows = 2;
cols = 2;
}
else else
{ {
// Default to 2x2 layout for more than 3 images
rows = 2; rows = 2;
cols = 2; cols = 2;
} }
var image = Image.Black(dims.Width, dims.Height); var image = Image.Black(dims.Width, dims.Height);
var thumbnailWidth = image.Width / cols; var thumbnailWidth = image.Width / cols;

View File

@ -60,7 +60,6 @@ public interface IScrobblingService
public class ScrobblingService : IScrobblingService public class ScrobblingService : IScrobblingService
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ITokenService _tokenService;
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly ILogger<ScrobblingService> _logger; private readonly ILogger<ScrobblingService> _logger;
private readonly ILicenseService _licenseService; private readonly ILicenseService _licenseService;
@ -99,12 +98,10 @@ public class ScrobblingService : IScrobblingService
private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling"; private const string AccessTokenErrorMessage = "Access Token needs to be rotated to continue scrobbling";
public ScrobblingService(IUnitOfWork unitOfWork, ITokenService tokenService, public ScrobblingService(IUnitOfWork unitOfWork, IEventHub eventHub, ILogger<ScrobblingService> logger,
IEventHub eventHub, ILogger<ScrobblingService> logger, ILicenseService licenseService, ILicenseService licenseService, ILocalizationService localizationService)
ILocalizationService localizationService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_tokenService = tokenService;
_eventHub = eventHub; _eventHub = eventHub;
_logger = logger; _logger = logger;
_licenseService = licenseService; _licenseService = licenseService;

View File

@ -142,10 +142,15 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
// For everything that's not there, link it up for this user. // For everything that's not there, link it up for this user.
_logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems); _logger.LogInformation("Starting Sync on {CollectionName} with {SeriesCount} Series", info.Title, info.TotalItems);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, 0, info.TotalItems, ProgressEventType.Started));
var missingCount = 0; var missingCount = 0;
var missingSeries = new StringBuilder(); var missingSeries = new StringBuilder();
var counter = -1;
foreach (var seriesInfo in info.Series.OrderBy(s => s.SeriesName)) foreach (var seriesInfo in info.Series.OrderBy(s => s.SeriesName))
{ {
counter++;
try try
{ {
// Normalize series name and localized name // Normalize series name and localized name
@ -164,7 +169,12 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
s.NormalizedLocalizedName == normalizedSeriesName) s.NormalizedLocalizedName == normalizedSeriesName)
&& formats.Contains(s.Format)); && formats.Contains(s.Format));
if (existingSeries != null) continue; if (existingSeries != null)
{
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated));
continue;
}
// Series not found in the collection, try to find it in the server // Series not found in the collection, try to find it in the server
var newSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(seriesInfo.SeriesName, var newSeries = await _unitOfWork.SeriesRepository.GetSeriesByAnyName(seriesInfo.SeriesName,
@ -196,6 +206,8 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
missingSeries.Append("<br/>"); missingSeries.Append("<br/>");
} }
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, seriesInfo.SeriesName, counter, info.TotalItems, ProgressEventType.Updated));
} }
// At this point, all series in the info have been checked and added if necessary // At this point, all series in the info have been checked and added if necessary
@ -213,6 +225,9 @@ public class SmartCollectionSyncService : ISmartCollectionSyncService
await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collection); await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collection);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.SmartCollectionProgressEvent(info.Title, string.Empty, info.TotalItems, info.TotalItems, ProgressEventType.Ended));
await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated, await _eventHub.SendMessageAsync(MessageFactory.CollectionUpdated,
MessageFactory.CollectionUpdatedEvent(collection.Id), false); MessageFactory.CollectionUpdatedEvent(collection.Id), false);

View File

@ -33,17 +33,19 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private readonly IEventHub _eventHub; private readonly IEventHub _eventHub;
private readonly ICacheHelper _cacheHelper; private readonly ICacheHelper _cacheHelper;
private readonly IReaderService _readerService; private readonly IReaderService _readerService;
private readonly IMediaErrorService _mediaErrorService;
private const int AverageCharactersPerWord = 5; private const int AverageCharactersPerWord = 5;
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub, public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
ICacheHelper cacheHelper, IReaderService readerService) ICacheHelper cacheHelper, IReaderService readerService, IMediaErrorService mediaErrorService)
{ {
_logger = logger; _logger = logger;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_eventHub = eventHub; _eventHub = eventHub;
_cacheHelper = cacheHelper; _cacheHelper = cacheHelper;
_readerService = readerService; _readerService = readerService;
_mediaErrorService = mediaErrorService;
} }
@ -188,7 +190,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress, MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
ProgressEventType.Updated, useFileName ? filePath : series.Name)); ProgressEventType.Updated, useFileName ? filePath : series.Name));
sum += await GetWordCountFromHtml(bookPage); sum += await GetWordCountFromHtml(bookPage, filePath);
pageCounter++; pageCounter++;
} }
@ -245,13 +247,23 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
} }
private static async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile) private async Task<int> GetWordCountFromHtml(EpubLocalTextContentFileRef bookFile, string filePath)
{ {
var doc = new HtmlDocument(); try
doc.LoadHtml(await bookFile.ReadContentAsync()); {
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsync());
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]"); var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0; return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) / AverageCharactersPerWord ?? 0;
}
catch (EpubContentException ex)
{
_logger.LogError(ex, "Error when counting words in epub {EpubPath}", filePath);
await _mediaErrorService.ReportMediaIssueAsync(filePath, MediaErrorProducer.BookService,
$"Invalid Epub Metadata, {bookFile.FilePath} does not exist", ex.Message);
return 0;
}
} }
} }

View File

@ -178,7 +178,7 @@ public class ThemeService : IThemeService
themeDtos.Add(dto); themeDtos.Add(dto);
} }
_cache.Set(themeDtos, themes, _cacheOptions); _cache.Set(cacheKey, themeDtos, _cacheOptions);
return themeDtos; return themeDtos;
} }

View File

@ -134,6 +134,10 @@ public static class MessageFactory
/// A Theme was updated and UI should refresh to get the latest version /// A Theme was updated and UI should refresh to get the latest version
/// </summary> /// </summary>
public const string SiteThemeUpdated = "SiteThemeUpdated"; public const string SiteThemeUpdated = "SiteThemeUpdated";
/// <summary>
/// A Progress event when a smart collection is synchronizing
/// </summary>
public const string SmartCollectionSync = "SmartCollectionSync";
public static SignalRMessage DashboardUpdateEvent(int userId) public static SignalRMessage DashboardUpdateEvent(int userId)
{ {
@ -425,6 +429,31 @@ public static class MessageFactory
}; };
} }
/// <summary>
/// Represents a file being scanned by Kavita for processing and grouping
/// </summary>
/// <remarks>Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate</remarks>
/// <param name="folderPath"></param>
/// <param name="libraryName"></param>
/// <param name="eventType"></param>
/// <returns></returns>
public static SignalRMessage SmartCollectionProgressEvent(string collectionName, string seriesName, int currentItems, int totalItems, string eventType)
{
return new SignalRMessage()
{
Name = SmartCollectionSync,
Title = $"Synchronizing {collectionName}",
SubTitle = seriesName,
EventType = eventType,
Progress = ProgressType.Determinate,
Body = new
{
Progress = float.Min((currentItems / (totalItems * 1.0f)), 100f),
EventTime = DateTime.Now
}
};
}
/// <summary> /// <summary>
/// This informs the UI with details about what is being processed by the Scanner /// This informs the UI with details about what is being processed by the Scanner
/// </summary> /// </summary>

View File

@ -266,6 +266,9 @@ public class Startup
// v0.8.1 // v0.8.1
await MigrateLowestSeriesFolderPath.Migrate(dataContext, unitOfWork, logger); await MigrateLowestSeriesFolderPath.Migrate(dataContext, unitOfWork, logger);
// v0.8.2
await ManualMigrateThemeDescription.Migrate(dataContext, logger);
// Update the version in the DB after all migrations are run // Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString();

View File

@ -14,7 +14,7 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [NodeJS](https://nodejs.org/en/download/) (Node 18.13.X or higher) - [NodeJS](https://nodejs.org/en/download/) (Node 18.13.X or higher)
- .NET 8.0+ - .NET 8.0+
- dotnet tool install -g --version 6.5.0 Swashbuckle.AspNetCore.Cli - dotnet tool install -g Swashbuckle.AspNetCore.Cli
### Getting started ### ### Getting started ###

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" /> <PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.24.0.89429"> <PackageReference Include="SonarAnalyzer.CSharp" Version="9.25.0.90414">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -1,4 +1,4 @@
$scrollbarHeight: 34px; $scrollbarHeight: 35px;
img { img {
user-select: none; user-select: none;
@ -9,29 +9,31 @@ img {
align-items: center; align-items: center;
&.full-width { &.full-width {
height: calc(var(--vh)*100); height: 100dvh;
display: grid; display: grid;
} }
&.full-height { &.full-height {
height: calc(100vh); // We need to - $scrollbarHeight when there is a horizontal scroll on macos height: calc(100dvh); // We need to - $scrollbarHeight when there is a horizontal scroll on macos
display: flex; display: flex;
align-content: center; align-content: center;
overflow-y: hidden;
} }
&.original { &.original {
height: 100vh; height: calc(100dvh);
display: grid; display: grid;
} }
.full-height { .full-height {
width: auto; width: auto;
margin: auto; margin: auto;
max-height: calc(var(--vh)*100); max-height: calc(100dvh);
overflow: hidden; // This technically will crop and make it just fit height: calc(100dvh);
vertical-align: top; vertical-align: top;
object-fit: cover;
&.wide { &.wide {
height: 100vh; height: calc(100dvh);
} }
} }
@ -46,12 +48,13 @@ img {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
vertical-align: top; vertical-align: top;
max-width: fit-content; object-fit: contain;
width: 100%;
} }
.fit-to-screen.full-width { .fit-to-screen.full-width {
width: 100%; width: 100%;
max-height: calc(var(--vh)*100); max-height: calc(100dvh);
} }
} }

View File

@ -10,7 +10,6 @@ import { Volume } from '../_models/volume';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { DeviceService } from './device.service'; import { DeviceService } from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {User} from "../_models/user";
export enum Action { export enum Action {
Submenu = -1, Submenu = -1,

View File

@ -103,7 +103,11 @@ export enum EVENTS {
/** /**
* A Theme was updated and UI should refresh to get the latest version * A Theme was updated and UI should refresh to get the latest version
*/ */
SiteThemeUpdated= 'SiteThemeUpdated' SiteThemeUpdated = 'SiteThemeUpdated',
/**
* A Progress event when a smart collection is synchronizing
*/
SmartCollectionSync = 'SmartCollectionSync'
} }
export interface Message<T> { export interface Message<T> {
@ -199,6 +203,13 @@ export class MessageHubService {
}); });
}); });
this.hubConnection.on(EVENTS.SmartCollectionSync, resp => {
this.messagesSource.next({
event: EVENTS.NotificationProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => { this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => {
this.messagesSource.next({ this.messagesSource.next({
event: EVENTS.SiteThemeUpdated, event: EVENTS.SiteThemeUpdated,

View File

@ -24,11 +24,11 @@
<div class="card-footer bg-transparent text-muted"> <div class="card-footer bg-transparent text-muted">
<div> <div>
@if (isMyReview) { @if (isMyReview) {
<i class="d-md-none fa-solid fa-star me-1" aria-hidden="true" [title]="t('your-review')"></i> <i class="d-md-none fa-solid fa-star me-2" aria-hidden="true" [title]="t('your-review')"></i>
<img class="me-1" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="20" height="20" alt=""> <img class="me-2" [ngSrc]="ScrobbleProvider.Kavita | providerImage" width="20" height="20" alt="">
{{review.username}} {{review.username}}
} @else { } @else {
<img class="me-1" [ngSrc]="review.provider | providerImage" width="20" height="20" alt=""> <img class="me-2" [ngSrc]="review.provider | providerImage" width="20" height="20" alt="">
} }
{{(isMyReview ? '' : review.username | defaultValue:'')}} {{(isMyReview ? '' : review.username | defaultValue:'')}}

View File

@ -42,4 +42,10 @@
max-width: 319px; max-width: 319px;
justify-content: space-between; justify-content: space-between;
margin: 0 auto; margin: 0 auto;
padding: .5rem 0;
& > * {
margin: 0 5px;
display: inline-flex;
}
} }

View File

@ -1,6 +1,7 @@
<ng-container *transloco="let t; read:'user-scrobble-history'"> <ng-container *transloco="let t; read:'user-scrobble-history'">
<h5>{{t('title')}}</h5> <h5>{{t('title')}}</h5>
<p>{{t('description')}}</p> <p>{{t('description')}}</p>
<p class="fw-bold">{{t('not-read-warning')}}</p>
<div class="row g-0 mb-2"> <div class="row g-0 mb-2">
<div class="col-md-10"> <div class="col-md-10">
<form [formGroup]="formGroup"> <form [formGroup]="formGroup">
@ -67,7 +68,11 @@
@switch (item.scrobbleEventType) { @switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) { @case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) { @if(item.volumeNumber === LooseLeafOrDefaultNumber) {
{{t('chapter-num', {num: item.chapterNumber})}} @if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
} }
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) { @else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}} {{t('volume-num', {num: item.volumeNumber})}}

View File

@ -3,8 +3,12 @@
<form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined"> <form [formGroup]="settingsForm" *ngIf="serverSettings !== undefined">
<h4 id="email-header">{{t('title')}}</h4> <h4 id="email-header">{{t('title')}}</h4>
<p>You must fill out both Host Name and SMTP settings to use email-based functionality within Kavita.</p> <p>{{t('setting-description')}}</p>
@if (settingsForm.dirty) {
<ngb-alert [type]="'warning'">
{{t('test-warning')}}
</ngb-alert>
}
<div class="mb-3 pe-2 ps-2 "> <div class="mb-3 pe-2 ps-2 ">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i> <label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template> <ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>

View File

@ -5,6 +5,7 @@ import {take} from 'rxjs';
import {SettingsService} from '../settings.service'; import {SettingsService} from '../settings.service';
import {ServerSettings} from '../_models/server-settings'; import {ServerSettings} from '../_models/server-settings';
import { import {
NgbAlert,
NgbTooltip NgbTooltip
} from '@ng-bootstrap/ng-bootstrap'; } from '@ng-bootstrap/ng-bootstrap';
import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common'; import {NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
@ -19,7 +20,7 @@ import {ManageAlertsComponent} from "../manage-alerts/manage-alerts.component";
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe, imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgTemplateOutlet, TranslocoModule, SafeHtmlPipe,
ManageAlertsComponent, TitleCasePipe] ManageAlertsComponent, TitleCasePipe, NgbAlert]
}) })
export class ManageEmailSettingsComponent implements OnInit { export class ManageEmailSettingsComponent implements OnInit {

View File

@ -14,7 +14,7 @@
<ng-template #cardItem let-item let-position="idx"> <ng-template #cardItem let-item let-position="idx">
<!-- TODO: figure a way to get a hover effect --> <!-- TODO: figure a way to get a hover effect -->
<div class="card-item-container card clickable" (click)="loadSmartFilter(item)"> <div class="card-item-container card clickable" (click)="loadSmartFilter(item)">
<div class="overlay"> <div class="overlay filter">
<div class="card-overlay"></div> <div class="card-overlay"></div>
<div class="overlay-information overlay-information--centered"> <div class="overlay-information overlay-information--centered">
<div class="position-relative"> <div class="position-relative">

View File

@ -1,7 +1,7 @@
<ng-container *transloco="let t; read: 'series-info-cards'"> <ng-container *transloco="let t; read: 'series-info-cards'">
<div class="row g-0 mt-3"> <div class="row g-0 mt-3">
<ng-container *ngIf="seriesMetadata.releaseYear > 0"> <ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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')"> <app-icon-and-title [label]="t('release-date-title')" [clickable]="false" fontClasses="fa-regular fa-calendar" [title]="t('release-year-tooltip')">
{{seriesMetadata.releaseYear}} {{seriesMetadata.releaseYear}}
</app-icon-and-title> </app-icon-and-title>
@ -11,7 +11,7 @@
<ng-container *ngIf="seriesMetadata"> <ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating"> <ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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')"> <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}} {{this.seriesMetadata.ageRating | ageRating}}
</app-icon-and-title> </app-icon-and-title>
@ -20,7 +20,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''"> <ng-container *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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')"> <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}} {{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title> </app-icon-and-title>
@ -30,7 +30,7 @@
</ng-container> </ng-container>
<ng-container> <ng-container>
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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"> <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'}}" <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)" (click)="handleGoTo(FilterField.PublicationStatus, seriesMetadata.publicationStatus)"
@ -43,7 +43,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="accountService.hasValidLicense$ | async"> <ng-container *ngIf="accountService.hasValidLicense$ | async">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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" <app-icon-and-title [label]="t('scrobbling-title')" [clickable]="libraryAllowsScrobbling"
fontClasses="fa-solid fa-tower-{{(isScrobbling && libraryAllowsScrobbling) ? 'broadcast' : 'observation'}}" fontClasses="fa-solid fa-tower-{{(isScrobbling && libraryAllowsScrobbling) ? 'broadcast' : 'observation'}}"
(click)="toggleScrobbling($event)" (click)="toggleScrobbling($event)"
@ -62,7 +62,7 @@
<ng-container *ngIf="series"> <ng-container *ngIf="series">
<ng-container> <ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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" <app-icon-and-title [label]="t('format-title')" [clickable]="true"
[fontClasses]="series.format | mangaFormatIcon" [fontClasses]="series.format | mangaFormatIcon"
(click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')"> (click)="handleGoTo(FilterField.Formats, series.format)" [title]="t('format-title')">
@ -73,7 +73,7 @@
</ng-container> </ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'"> <ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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')"> <app-icon-and-title [label]="t('last-read-title')" [clickable]="false" fontClasses="fa-regular fa-clock" [title]="t('last-read-title')">
{{series.latestReadDate | timeAgo}} {{series.latestReadDate | timeAgo}}
</app-icon-and-title> </app-icon-and-title>
@ -83,7 +83,7 @@
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages"> <ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0"> <ng-container *ngIf="series.wordCount > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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"> <app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{t('words-count', {num: series.wordCount | compactNumber})}} {{t('words-count', {num: series.wordCount | compactNumber})}}
</app-icon-and-title> </app-icon-and-title>
@ -93,7 +93,7 @@
</ng-container> </ng-container>
<ng-template #showPages> <ng-template #showPages>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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"> <app-icon-and-title [label]="t('length-title')" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{t('pages-count', {num: series.pages | compactNumber})}} {{t('pages-count', {num: series.pages | compactNumber})}}
</app-icon-and-title> </app-icon-and-title>
@ -102,7 +102,7 @@
</ng-template> </ng-template>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB"> <ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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"> <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-container *ngIf="readingTime.maxHours === 0 || readingTime.minHours === 0; else normalReadTime">{{t('less-than-hour')}}</ng-container>
<ng-template #normalReadTime> <ng-template #normalReadTime>
@ -114,7 +114,7 @@
<ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && readingTimeLeft.avgHours !== 0"> <ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && readingTimeLeft.avgHours !== 0">
<div class="vr d-none d-lg-block m-2"></div> <div class="vr d-none d-lg-block m-2"></div>
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2"> <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"> <app-icon-and-title label="Time Left" [clickable]="false" fontClasses="fa-solid fa-clock">
~{{readingTimeLeft.avgHours}} {{readingTimeLeft.avgHours > 1 ? t('hours') : t('hour')}} ~{{readingTimeLeft.avgHours}} {{readingTimeLeft.avgHours > 1 ? t('hours') : t('hour')}}
</app-icon-and-title> </app-icon-and-title>

View File

@ -6,11 +6,13 @@ import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {CollectionTagService} from "../../../_services/collection-tag.service"; import {CollectionTagService} from "../../../_services/collection-tag.service";
import {MalStack} from "../../../_models/collection/mal-stack"; import {MalStack} from "../../../_models/collection/mal-stack";
import {UserCollection} from "../../../_models/collection-tag"; import {UserCollection} from "../../../_models/collection-tag";
import {ScrobbleProvider} from "../../../_services/scrobbling.service"; import {ScrobbleProvider, ScrobblingService} from "../../../_services/scrobbling.service";
import {forkJoin} from "rxjs"; import {forkJoin} from "rxjs";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {DecimalPipe} from "@angular/common"; import {DecimalPipe} from "@angular/common";
import {LoadingComponent} from "../../../shared/loading/loading.component"; import {LoadingComponent} from "../../../shared/loading/loading.component";
import {AccountService} from "../../../_services/account.service";
import {ConfirmService} from "../../../shared/confirm.service";
@Component({ @Component({
selector: 'app-import-mal-collection-modal', selector: 'app-import-mal-collection-modal',
@ -32,13 +34,25 @@ export class ImportMalCollectionModalComponent {
private readonly collectionService = inject(CollectionTagService); private readonly collectionService = inject(CollectionTagService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
private readonly scrobblingService = inject(ScrobblingService);
private readonly confirmService = inject(ConfirmService);
stacks: Array<MalStack> = []; stacks: Array<MalStack> = [];
isLoading = true; isLoading = true;
collectionMap: {[key: string]: UserCollection | MalStack} = {}; collectionMap: {[key: string]: UserCollection | MalStack} = {};
constructor() { constructor() {
this.scrobblingService.getMalToken().subscribe(async token => {
if (token.accessToken === '') {
await this.confirmService.alert(translate('toasts.mal-token-required'));
this.ngbModal.dismiss();
return;
}
this.setup();
});
}
setup() {
forkJoin({ forkJoin({
allCollections: this.collectionService.allCollections(true), allCollections: this.collectionService.allCollections(true),
malStacks: this.collectionService.getMalStacks() malStacks: this.collectionService.getMalStacks()

View File

@ -3,18 +3,25 @@
.image-container { .image-container {
#image-1 { #image-1 {
&.double { &.double {
margin: 0 0 0 auto;
} }
} }
} }
.image-container.full-height { .image-container {
display: inline-block !important; &.full-height {
display: flex;
align-content: center;
justify-content: center;
}
.full-height {
margin: unset;
object-fit: contain;
}
} }
.full-width { .full-width {
width: 100%; width: 100%;
margin: 0 auto;
vertical-align: top; vertical-align: top;
max-width: fit-content; max-width: fit-content;
@ -41,7 +48,7 @@
} }
.fit-to-height-double-offset { .fit-to-height-double-offset {
height: 100vh; height: calc(100dvh);
object-fit: scale-down; object-fit: scale-down;
top: 50%; top: 50%;
left: 50%; left: 50%;

View File

@ -2,7 +2,7 @@
// Overrides for reverse // Overrides for reverse
.image-container { .image-container {
height: calc(100vh); // override as on single, we -34px for the potential scrollbar height: calc(100dvh); // override as on single, we -34px for the potential scrollbar
&.reverse { &.reverse {
overflow: unset; overflow: unset;
@ -29,8 +29,16 @@
} }
} }
.image-container.full-height { .image-container {
display: inline-block; display: flex;
align-content: center;
justify-content: center;
.full-height {
margin: unset;
object-fit: contain;
}
} }
.full-width { .full-width {
@ -62,7 +70,7 @@
} }
.fit-to-height-double-offset { .fit-to-height-double-offset {
height: 100vh; height: calc(100dvh);
object-fit: scale-down; object-fit: scale-down;
top: 50%; top: 50%;
left: 50%; left: 50%;

View File

@ -41,7 +41,7 @@
<app-loading [loading]="isLoading || (!(currentImage$ | async)?.complete && this.readerMode !== ReaderMode.Webtoon)" [absolute]="true"></app-loading> <app-loading [loading]="isLoading || (!(currentImage$ | async)?.complete && this.readerMode !== ReaderMode.Webtoon)" [absolute]="true"></app-loading>
<div class="reading-area" <div class="reading-area"
ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)" ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)"
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : 'calc(var(--vh)*100)'}" #readingArea> [ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea>
<ng-container *ngIf="readerMode !== ReaderMode.Webtoon; else webtoon"> <ng-container *ngIf="readerMode !== ReaderMode.Webtoon; else webtoon">
<div (dblclick)="bookmarkPage($event)"> <div (dblclick)="bookmarkPage($event)">
@ -56,14 +56,14 @@
<!-- Pagination controls and screen hints--> <!-- Pagination controls and screen hints-->
<div class="pagination-area"> <div class="pagination-area">
<div class="{{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, KeyDirection.Left)" <div class="{{readerMode === ReaderMode.LeftRight ? 'left' : 'top'}} {{clickOverlayClass('left')}}" (click)="handlePageChange($event, KeyDirection.Left)"
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? ImageHeight: '25%'), 'max-height': MaxHeight}"> [ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'), 'max-height': MaxHeight}">
<div *ngIf="showClickOverlay"> <div *ngIf="showClickOverlay">
<i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}" <i class="fa fa-angle-{{readingDirection === ReadingDirection.RightToLeft ? 'double-' : ''}}{{readerMode === ReaderMode.LeftRight ? 'left' : 'up'}}"
[title]="t('prev-page-tooltip')" aria-hidden="true"></i> [title]="t('prev-page-tooltip')" aria-hidden="true"></i>
</div> </div>
</div> </div>
<div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, KeyDirection.Right)" <div class="{{readerMode === ReaderMode.LeftRight ? 'right' : 'bottom'}} {{clickOverlayClass('right')}}" (click)="handlePageChange($event, KeyDirection.Right)"
[ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? ImageHeight: '25%'), [ngStyle]="{'height': (readerMode === ReaderMode.LeftRight ? MaxHeight: '25%'),
'left': 'inherit', 'left': 'inherit',
'right': RightPaginationOffset + 'px', 'right': RightPaginationOffset + 'px',
'max-height': MaxHeight}"> 'max-height': MaxHeight}">

View File

@ -16,7 +16,6 @@ $pointer-offset: 5px;
.reading-area { .reading-area {
position: relative;
overflow: auto; overflow: auto;
text-align: center; text-align: center;
//height: calc(var(--vh)*100); // this needs to be applied on the DOM because it breaks infinite scroller //height: calc(var(--vh)*100); // this needs to be applied on the DOM because it breaks infinite scroller

View File

@ -432,17 +432,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
// This is for the pagination area // This is for the pagination area
get MaxHeight() { get MaxHeight() {
if (this.FittingOption === FITTING_OPTION.HEIGHT) { return '100dvh';
return 'calc(var(--vh) * 100)';
}
const needsScrolling = this.readingArea?.nativeElement?.scrollHeight > this.readingArea?.nativeElement?.clientHeight;
if (this.readingArea?.nativeElement?.clientHeight <= this.mangaReaderService.getPageDimensions(this.pageNum)?.height!) {
if (needsScrolling) {
return Math.min(this.readingArea?.nativeElement?.scrollHeight, this.mangaReaderService.getPageDimensions(this.pageNum)?.height!) + 'px';
}
}
return this.readingArea?.nativeElement?.clientHeight + 'px';
} }
get RightPaginationOffset() { get RightPaginationOffset() {

View File

@ -150,14 +150,14 @@ export class SingleRendererComponent implements OnInit, ImageRenderer {
if (mode !== FITTING_OPTION.HEIGHT) return ''; if (mode !== FITTING_OPTION.HEIGHT) return '';
const readingArea = this.document.querySelector('.reading-area'); const readingArea = this.document.querySelector('.reading-area');
if (!readingArea) return 'calc(100vh)'; if (!readingArea) return 'calc(100dvh)';
// If you ever see fit to height and a bit of scrollbar, it's due to currentImage not being ready on first load // If you ever see fit to height and a bit of scrollbar, it's due to currentImage not being ready on first load
if (this.currentImage?.width - readingArea.scrollWidth > 0) { if (this.currentImage?.width - readingArea.scrollWidth > 0) {
// we also need to check if this is FF or Chrome. FF doesn't require the -34px as it doesn't render a scrollbar // we also need to check if this is FF or Chrome. FF doesn't require the -34px as it doesn't render a scrollbar
return 'calc(100vh - 34px)'; return 'calc(100dvh)';
} }
return 'calc(100vh)'; return 'calc(100dvh)';
}), }),
filter(_ => this.isValid()) filter(_ => this.isValid())
); );

View File

@ -118,7 +118,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
break; break;
case 'started': case 'started':
// Sometimes we can receive 2 started on long running scans, so better to just treat as a merge then. // Sometimes we can receive 2 started on long-running scans, so better to just treat as a merge then.
data = this.mergeOrUpdate(this.progressEventsSource.getValue(), message); data = this.mergeOrUpdate(this.progressEventsSource.getValue(), message);
this.progressEventsSource.next(data); this.progressEventsSource.next(data);
break; break;

View File

@ -3,137 +3,145 @@
<h2 title> <h2 title>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.aria-labelledby]="readingList?.title" *ngIf="actions.length > 0"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [attr.aria-labelledby]="readingList?.title" *ngIf="actions.length > 0"></app-card-actionables>
{{readingList?.title}} {{readingList?.title}}
<span *ngIf="readingList?.promoted" class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span> @if (readingList?.promoted) {
<span class="ms-1">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
}
</h2> </h2>
<h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h6> <h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: items.length | number})}}</h6>
<ng-template #extrasDrawer let-offcanvas> <ng-template #extrasDrawer let-offcanvas>
<div style="margin-top: 56px" *ngIf="readingList"> @if (readingList) {
<div class="offcanvas-header"> <div style="margin-top: 56px">
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4> <div class="offcanvas-header">
<button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button> <h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
</div> <button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button>
<div class="offcanvas-body"> </div>
<div class="row g-0"> <div class="offcanvas-body">
<div class="col-md-12 col-sm-12 pe-2 mb-3"> <div class="row g-0">
<button class="btn btn-danger" (click)="removeRead()" [disabled]="readingList.promoted && !this.isAdmin"> <div class="col-md-12 col-sm-12 pe-2 mb-3">
<button class="btn btn-danger" (click)="removeRead()" [disabled]="readingList.promoted && !this.isAdmin">
<span> <span>
<i class="fa fa-check"></i> <i class="fa fa-check"></i>
</span> </span>
<span class="read-btn--text">&nbsp;{{t('remove-read')}}</span> <span class="read-btn--text">&nbsp;{{t('remove-read')}}</span>
</button> </button>
<div class="col-auto ms-2 mt-2" *ngIf="!(readingList?.promoted && !this.isAdmin)"> @if (!(readingList.promoted && !this.isAdmin)) {
<div class="form-check form-check-inline"> <div class="col-auto ms-2 mt-2">
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()"> <div class="form-check form-check-inline">
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label> <input class="form-check-input" type="checkbox" id="accessibility-mode" [disabled]="this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
</div> <label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
</div>
</div>
}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> }
</ng-template> </ng-template>
</app-side-nav-companion-bar> </app-side-nav-companion-bar>
<div class="container-fluid mt-2" *ngIf="readingList" >
<div class="row mb-2"> @if (readingList) {
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block"> <div class="container-fluid mt-2">
<app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image>
</div> <div class="row mb-2">
<div class="col-md-10 col-xs-8 col-sm-6 mt-2"> <div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<div class="row g-0 mb-3"> <app-image [styles]="{'max-height': '400px', 'max-width': '300px'}" [imageUrl]="imageService.getReadingListCoverImage(readingList.id)"></app-image>
<div class="col-auto me-2"> </div>
<!-- Action row--> <div class="col-md-10 col-xs-8 col-sm-6 mt-2">
<div class="btn-group me-3"> <div class="row g-0 mb-3">
<button type="button" class="btn btn-primary" (click)="continue()"> <div class="col-auto me-2">
<!-- Action row-->
<div class="btn-group me-3">
<button type="button" class="btn btn-primary" (click)="continue()">
<span> <span>
<i class="fa fa-book-open me-1" aria-hidden="true"></i> <i class="fa fa-book-open me-1" aria-hidden="true"></i>
<span class="read-btn--text">{{t('continue')}}</span> <span class="read-btn--text">{{t('continue')}}</span>
</span> </span>
</button> </button>
<div class="btn-group" ngbDropdown role="group" [attr.aria-label]="t('read-options-alt')"> <div class="btn-group" ngbDropdown role="group" [attr.aria-label]="t('read-options-alt')">
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button> <button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
<div class="dropdown-menu" ngbDropdownMenu> <div class="dropdown-menu" ngbDropdownMenu>
<button ngbDropdownItem (click)="read()"> <button ngbDropdownItem (click)="read()">
<span> <span>
<i class="fa fa-book" aria-hidden="true"></i> <i class="fa fa-book" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{t('read')}}</span> <span class="read-btn--text">&nbsp;{{t('read')}}</span>
</span> </span>
</button> </button>
<button ngbDropdownItem (click)="continue(true)"> <button ngbDropdownItem (click)="continue(true)">
<span> <span>
<i class="fa fa-book-open me-1" aria-hidden="true"></i> <i class="fa fa-book-open me-1" aria-hidden="true"></i>
<span class="read-btn--text">{{t('continue')}}</span> <span class="read-btn--text">{{t('continue')}}</span>
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>) (<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
<span class="visually-hidden">{{t('incognito-alt')}}</span> <span class="visually-hidden">{{t('incognito-alt')}}</span>
</span> </span>
</button> </button>
<button ngbDropdownItem (click)="read(true)"> <button ngbDropdownItem (click)="read(true)">
<span> <span>
<i class="fa fa-book me-1" aria-hidden="true"></i> <i class="fa fa-book me-1" aria-hidden="true"></i>
<span class="read-btn--text">&nbsp;{{t('read')}}</span> <span class="read-btn--text">&nbsp;{{t('read')}}</span>
(<i class="fa fa-glasses ms-1" aria-hidden="true"></i>) (<i class="fa fa-glasses ms-1" aria-hidden="true"></i>)
<span class="visually-hidden">{{t('incognito-alt')}}</span> <span class="visually-hidden">{{t('incognito-alt')}}</span>
</span> </span>
</button> </button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> <div class="row g-0 mt-2" *ngIf="readingList.startingYear !== 0">
<div class="row g-0 mt-2" *ngIf="readingList.startingYear !== 0"> <h4 class="reading-list-years">
<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.startingMonth +'/01/2020')| date:'MMM'}}</ng-container> <ng-container *ngIf="readingList.startingMonth > 0 && readingList.startingYear > 0">, </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.startingYear > 0">{{readingList.startingYear}}</ng-container>
<ng-container *ngIf="readingList.endingYear > 0">
<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.endingMonth +'/01/2020') | date:'MMM'}}</ng-container> <ng-container *ngIf="readingList.endingMonth > 0 && readingList.endingYear > 0">, </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 *ngIf="readingList.endingYear > 0">{{readingList.endingYear}}</ng-container> </ng-container>
</ng-container>
</h4> </h4>
</div> </div>
<!-- Summary row--> <!-- Summary row-->
<div class="row g-0 mt-2"> <div class="row g-0 mt-2">
<app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more> <app-read-more [text]="readingListSummary" [maxLength]="250"></app-read-more>
</div>
</div> </div>
</div> </div>
</div>
<ng-container *ngIf="characters$ | async as characters"> <ng-container *ngIf="characters$ | async as characters">
<div class="row mb-2"> <div class="row mb-2">
<div class="row" *ngIf="characters && characters.length > 0"> <div class="row" *ngIf="characters && characters.length > 0">
<h5>{{t('characters-title')}}</h5> <h5>{{t('characters-title')}}</h5>
<app-badge-expander [items]="characters"> <app-badge-expander [items]="characters">
<ng-template #badgeExpanderItem let-item let-position="idx"> <ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge> <app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge>
</ng-template> </ng-template>
</app-badge-expander> </app-badge-expander>
</div> </div>
</div>
</ng-container>
<div class="row mb-1 scroll-container" #scrollingBlock>
<ng-container *ngIf="items.length === 0 && !isLoading; else loading">
<div class="mx-auto" style="width: 200px;">
{{t('no-data')}}
</div> </div>
</ng-container> </ng-container>
<ng-template #loading>
<app-loading *ngIf="isLoading" [loading]="isLoading"></app-loading>
</ng-template>
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode" <div class="row mb-1 scroll-container" #scrollingBlock>
[showRemoveButton]="false"> <ng-container *ngIf="items.length === 0 && !isLoading; else loading">
<ng-template #draggableItem let-item let-position="idx"> <div class="mx-auto" style="width: 200px;">
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item" [position]="position" [libraryTypes]="libraryTypes" {{t('no-data')}}
[promoted]="item.promoted" (read)="readChapter($event)" (remove)="itemRemoved($event, position)"></app-reading-list-item> </div>
</ng-container>
<ng-template #loading>
<app-loading *ngIf="isLoading" [loading]="isLoading"></app-loading>
</ng-template> </ng-template>
</app-draggable-ordered-list>
</div>
</div>
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
[showRemoveButton]="false">
<ng-template #draggableItem let-item let-position="idx">
<app-reading-list-item [ngClass]="{'content-container': items.length < 100, 'non-virtualized-container': items.length >= 100}" [item]="item" [position]="position" [libraryTypes]="libraryTypes"
[promoted]="item.promoted" (read)="readChapter($event)" (remove)="itemRemoved($event, position)"></app-reading-list-item>
</ng-template>
</app-draggable-ordered-list>
</div>
</div>
}
</ng-container> </ng-container>

View File

@ -3,7 +3,7 @@ import {ActivatedRoute, Router} from '@angular/router';
import {ToastrService} from 'ngx-toastr'; import {ToastrService} from 'ngx-toastr';
import {take} from 'rxjs/operators'; import {take} from 'rxjs/operators';
import {ConfirmService} from 'src/app/shared/confirm.service'; import {ConfirmService} from 'src/app/shared/confirm.service';
import {UtilityService} from 'src/app/shared/_services/utility.service'; import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library'; import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format'; import {MangaFormat} from 'src/app/_models/manga-format';
import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list'; import {ReadingList, ReadingListItem} from 'src/app/_models/reading-list';
@ -32,7 +32,7 @@ import {AsyncPipe, DatePipe, DecimalPipe, NgClass, NgIf} from '@angular/common';
import { import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco"; import {translate, TranslocoDirective} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service"; import {FilterUtilitiesService} from "../../../shared/_services/filter-utilities.service";
import {FilterField} from "../../../_models/metadata/v2/filter-field"; import {FilterField} from "../../../_models/metadata/v2/filter-field";
@ -53,6 +53,26 @@ import {Title} from "@angular/platform-browser";
MetadataDetailComponent] MetadataDetailComponent]
}) })
export class ReadingListDetailComponent implements OnInit { export class ReadingListDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private readingListService = inject(ReadingListService);
private actionService = inject(ActionService);
private actionFactoryService = inject(ActionFactoryService);
public utilityService = inject(UtilityService);
public imageService = inject(ImageService);
private accountService = inject(AccountService);
private toastr = inject(ToastrService);
private confirmService = inject(ConfirmService);
private libraryService = inject(LibraryService);
private readerService = inject(ReaderService);
private cdRef = inject(ChangeDetectorRef);
private filterUtilityService = inject(FilterUtilitiesService);
private titleService = inject(Title);
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
items: Array<ReadingListItem> = []; items: Array<ReadingListItem> = [];
listId!: number; listId!: number;
readingList: ReadingList | undefined; readingList: ReadingList | undefined;
@ -65,15 +85,8 @@ export class ReadingListDetailComponent implements OnInit {
libraryTypes: {[key: number]: LibraryType} = {}; libraryTypes: {[key: number]: LibraryType} = {};
characters$!: Observable<Person[]>; characters$!: Observable<Person[]>;
private translocoService = inject(TranslocoService);
protected readonly MangaFormat = MangaFormat;
constructor(private route: ActivatedRoute, private router: Router, private readingListService: ReadingListService,
private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService,
public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService,
private confirmService: ConfirmService, private libraryService: LibraryService, private readerService: ReaderService,
private readonly cdRef: ChangeDetectorRef, private filterUtilityService: FilterUtilitiesService, private titleService: Title) {
}
ngOnInit(): void { ngOnInit(): void {
const listId = this.route.snapshot.paramMap.get('id'); const listId = this.route.snapshot.paramMap.get('id');
@ -86,6 +99,9 @@ export class ReadingListDetailComponent implements OnInit {
this.listId = parseInt(listId, 10); this.listId = parseInt(listId, 10);
this.characters$ = this.readingListService.getCharacters(this.listId); this.characters$ = this.readingListService.getCharacters(this.listId);
this.accessibilityMode = this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet;
this.cdRef.markForCheck();
forkJoin([ forkJoin([
this.libraryService.getLibraries(), this.libraryService.getLibraries(),
this.readingListService.getReadingList(this.listId) this.readingListService.getReadingList(this.listId)
@ -165,10 +181,10 @@ export class ReadingListDetailComponent implements OnInit {
} }
async deleteList(readingList: ReadingList) { async deleteList(readingList: ReadingList) {
if (!await this.confirmService.confirm(this.translocoService.translate('toasts.confirm-delete-reading-list'))) return; if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;
this.readingListService.delete(readingList.id).subscribe(() => { this.readingListService.delete(readingList.id).subscribe(() => {
this.toastr.success(this.translocoService.translate('toasts.reading-list-deleted')); this.toastr.success(translate('toasts.reading-list-deleted'));
this.router.navigateByUrl('/lists'); this.router.navigateByUrl('/lists');
}); });
} }
@ -186,7 +202,7 @@ export class ReadingListDetailComponent implements OnInit {
this.items.splice(position, 1); this.items.splice(position, 1);
this.items = [...this.items]; this.items = [...this.items];
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.toastr.success(this.translocoService.translate('toasts.item-removed')); this.toastr.success(translate('toasts.item-removed'));
}); });
} }
@ -196,7 +212,7 @@ export class ReadingListDetailComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.readingListService.removeRead(this.readingList.id).subscribe((resp) => { this.readingListService.removeRead(this.readingList.id).subscribe((resp) => {
if (resp === 'Nothing to remove') { if (resp === 'Nothing to remove') {
this.toastr.info(this.translocoService.translate('toasts.nothing-to-remove')); this.toastr.info(translate('toasts.nothing-to-remove'));
return; return;
} }
this.getListItems(); this.getListItems();

View File

@ -8,7 +8,7 @@
<div class="row g-0 theme-container"> <div class="row g-0 theme-container">
<div class="col-md-3"> <div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller">
<div class="pe-2"> <div class="pe-2">
<ul style="height: 100%" class="list-group list-group-flush"> <ul style="height: 100%" class="list-group list-group-flush">
@ -23,93 +23,95 @@
</div> </div>
</div> </div>
<div class="col-md-9"> <div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
@if (selectedTheme === undefined) { <div class="card p-3">
<div class="row pb-4"> @if (selectedTheme === undefined) {
<div class="mx-auto">
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
@if (hasAdmin$ | async) {
{{t('preview-default-admin')}}
} @else {
{{t('preview-default')}}
}
<div class="row pb-4">
<div class="mx-auto">
<div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly">
@if (hasAdmin$ | async) {
{{t('preview-default-admin')}}
} @else {
{{t('preview-default')}}
}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
@if (files && files.length > 0) { @if (files && files.length > 0) {
<app-loading [loading]="isUploadingTheme"></app-loading> <app-loading [loading]="isUploadingTheme"></app-loading>
} @else if (hasAdmin$ | async) { } @else if (hasAdmin$ | async) {
<ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false" <ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false"
dropZoneClassName="file-upload" contentClassName="file-upload-zone"> dropZoneClassName="file-upload" contentClassName="file-upload-zone">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector"> <ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div class="row g-0 mt-3 pb-3"> <div class="row g-0 mt-3 pb-3">
<div class="mx-auto"> <div class="mx-auto">
<div class="row g-0 mb-3"> <div class="row g-0 mb-3">
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i> <i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
</div> </div>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<div class="d-flex justify-content-evenly"> <div class="d-flex justify-content-evenly">
<span class="pe-0" href="javascript:void(0)">{{t('drag-n-drop')}}</span> <span class="pe-0" href="javascript:void(0)">{{t('drag-n-drop')}}</span>
<span class="ps-1 pe-1"></span> <span class="ps-1 pe-1"></span>
<a class="pe-0" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a> <a class="pe-0" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</ng-template> </ng-template>
</ngx-file-drop>
}
</ngx-file-drop>
} }
@else {
} <h4>
@else { {{selectedTheme.name | sentenceCase}}
<h4> <div class="float-end">
{{selectedTheme.name | sentenceCase}} @if (selectedTheme.isSiteTheme) {
<div class="float-end"> @if (selectedTheme.name !== 'Dark') {
@if (selectedTheme.isSiteTheme) { <button class="btn btn-danger me-1" (click)="deleteTheme(selectedTheme.site!)">{{t('delete')}}</button>
@if (selectedTheme.name !== 'Dark') { }
<button class="btn btn-danger me-1" (click)="deleteTheme(selectedTheme.site!)">{{t('delete')}}</button> @if (hasAdmin$ | async) {
<button class="btn btn-secondary me-1" [disabled]="selectedTheme.site?.isDefault" (click)="updateDefault(selectedTheme.site!)">{{t('set-default')}}</button>
}
<button class="btn btn-primary me-1" [disabled]="currentTheme && selectedTheme.name === currentTheme.name" (click)="applyTheme(selectedTheme.site!)">{{t('apply')}}</button>
} @else {
<button class="btn btn-primary" [disabled]="selectedTheme.downloadable?.alreadyDownloaded" (click)="downloadTheme(selectedTheme.downloadable!)">{{t('download')}}</button>
} }
@if (hasAdmin$ | async) { </div>
<button class="btn btn-secondary me-1" [disabled]="selectedTheme.site?.isDefault" (click)="updateDefault(selectedTheme.site!)">{{t('set-default')}}</button> </h4>
} @if(!selectedTheme.isSiteTheme) {
<button class="btn btn-primary me-1" [disabled]="currentTheme && selectedTheme.name === currentTheme.name" (click)="applyTheme(selectedTheme.site!)">{{t('apply')}}</button> <p>{{selectedTheme.downloadable!.description | defaultValue}}</p>
} @else {
<button class="btn btn-primary" [disabled]="selectedTheme.downloadable?.alreadyDownloaded" (click)="downloadTheme(selectedTheme.downloadable!)">{{t('download')}}</button>
}
</div>
</h4>
@if(!selectedTheme.isSiteTheme) {
<p>{{selectedTheme.downloadable!.description | defaultValue}}</p>
<app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" title="Preview"> <app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" [title]="t('preview-title')">
<ng-template #carouselItem let-item> <ng-template #carouselItem let-item>
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer"> <a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image [imageUrl]="item" height="100px" width="160px"></app-image> <app-image [imageUrl]="item" height="108px" width="260px"></app-image>
</a> </a>
</ng-template> </ng-template>
</app-carousel-reel> </app-carousel-reel>
} @else { } @else {
<p>{{selectedTheme.site!.description | defaultValue}}</p> <p>{{selectedTheme.site!.description | defaultValue}}</p>
<app-carousel-reel [items]="selectedTheme.site!.previewUrls" title="Preview"> <app-carousel-reel [items]="selectedTheme.site!.previewUrls" [title]="t('preview-title')">
<ng-template #carouselItem let-item> <ng-template #carouselItem let-item>
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer"> <a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
<app-image [imageUrl]="item" height="100px" width="160px"></app-image> <app-image [imageUrl]="item" height="108px" width="260px"></app-image>
</a> </a>
</ng-template> </ng-template>
</app-carousel-reel> </app-carousel-reel>
}
} }
} </div>
</div> </div>
</div> </div>
</div> </div>
@ -122,12 +124,12 @@
<div class="fw-bold">{{item.name | sentenceCase}}</div> <div class="fw-bold">{{item.name | sentenceCase}}</div>
@if (item.hasOwnProperty('provider')) { @if (item.hasOwnProperty('provider')) {
{{item.provider | siteThemeProvider}} <span class="pill p-1 me-1 provider">{{item.provider | siteThemeProvider}}</span>
} @else if (item.hasOwnProperty('lastCompatibleVersion')) { } @else if (item.hasOwnProperty('lastCompatibleVersion')) {
{{ThemeProvider.Custom | siteThemeProvider}} • v{{item.lastCompatibleVersion}} <span class="pill p-1 me-1 provider">{{ThemeProvider.Custom | siteThemeProvider}}</span><span class="pill p-1 me-1 version">v{{item.lastCompatibleVersion}}</span>
} }
@if (currentTheme && item.name === currentTheme.name) { @if (currentTheme && item.name === currentTheme.name) {
• {{t('active-theme')}} <span class="pill p-1 active">{{t('active-theme')}}</span>
} }
</div> </div>
@if (item.hasOwnProperty('isDefault') && item.isDefault) { @if (item.hasOwnProperty('isDefault') && item.isDefault) {

View File

@ -10,6 +10,25 @@
justify-content: space-around; justify-content: space-around;
} }
.scroller {
max-height: calc(100dvh - 280px);
overflow-y: auto;
}
.pill {
font-size: .8rem;
background-color: var(--card-bg-color);
border-radius: 0.375rem;
&.active {
background-color : var(--primary-color);
}
}
.list-group-item, .list-group-item.active {
border-top-width: 0;
border-bottom-width: 0;
}
ngx-file-drop ::ng-deep > div { ngx-file-drop ::ng-deep > div {
// styling for the outer drop box // styling for the outer drop box
width: 100%; width: 100%;

View File

@ -33,6 +33,7 @@
"user-scrobble-history": { "user-scrobble-history": {
"title": "Scrobble History", "title": "Scrobble History",
"description": "Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.", "description": "Here you will find any scrobble events linked with your account. In order for events to exist, you must have an active scrobble provider configured. All events that have been processed will clear after a month. If there are non-processed events, it is likely these cannot form matches upstream. Please reach out to your admin to get them corrected.",
"not-read-warning": "Upstream providers will always keep the highest number",
"filter-label": "{{common.filter}}", "filter-label": "{{common.filter}}",
"created-header": "Created", "created-header": "Created",
"last-modified-header": "Last Modified", "last-modified-header": "Last Modified",
@ -47,7 +48,8 @@
"rating": "Rating {{r}}", "rating": "Rating {{r}}",
"not-applicable": "Not Applicable", "not-applicable": "Not Applicable",
"processed": "Processed", "processed": "Processed",
"not-processed": "Not Processed" "not-processed": "Not Processed",
"special": "{{entity-title.special}}"
}, },
"scrobble-event-type-pipe": { "scrobble-event-type-pipe": {
@ -197,7 +199,8 @@
"upload": "{{cover-image-chooser.upload}}", "upload": "{{cover-image-chooser.upload}}",
"upload-continued": "a css file", "upload-continued": "a css file",
"preview-default": "Select a theme first", "preview-default": "Select a theme first",
"preview-default-admin": "Select a theme first or upload one manually" "preview-default-admin": "Select a theme first or upload one manually",
"preview-title": "Preview"
}, },
"theme": { "theme": {
@ -1122,6 +1125,8 @@
"manage-email-settings": { "manage-email-settings": {
"title": "Email Services (SMTP)", "title": "Email Services (SMTP)",
"description": "In order to use some functions of Kavita like Forgot Password and Send To Device, an email provider must be setup. Other features like Password change are less secure without Email setup.", "description": "In order to use some functions of Kavita like Forgot Password and Send To Device, an email provider must be setup. Other features like Password change are less secure without Email setup.",
"setting-description": "You must fill out both Host Name and SMTP settings to use email-based functionality within Kavita.",
"test-warning": "You must save before using Test button.",
"send-to-warning": "If you want Send to Device to work you must setup your email settings", "send-to-warning": "If you want Send to Device to work you must setup your email settings",
"email-url-label": "Email Service URL", "email-url-label": "Email Service URL",
"email-url-tooltip": "Use fully qualified URL of the email service. Do not include ending slash.", "email-url-tooltip": "Use fully qualified URL of the email service. Do not include ending slash.",
@ -2199,8 +2204,8 @@
"collections-deleted": "Collections deleted", "collections-deleted": "Collections deleted",
"pdf-book-mode-screen-size": "Screen too small for Book mode", "pdf-book-mode-screen-size": "Screen too small for Book mode",
"stack-imported": "Stack Imported", "stack-imported": "Stack Imported",
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal" "confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal",
"mal-token-required": "MAL Token is required, set in User Settings"
}, },
"actionable": { "actionable": {

View File

@ -15,6 +15,7 @@
} }
$image-height: 230px; $image-height: 230px;
$image-filter-height: 160px;
$image-width: 160px; $image-width: 160px;
.card-item-container { .card-item-container {
@ -62,6 +63,14 @@ $image-width: 160px;
border-top-right-radius: 4px; border-top-right-radius: 4px;
z-index: 10; z-index: 10;
&.filter {
height: $image-filter-height;
.card-overlay {
height: $image-filter-height;
}
}
&:hover { &:hover {
visibility: visible; visibility: visible;
} }

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0", "name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
}, },
"version": "0.8.1.3" "version": "0.8.1.4"
}, },
"servers": [ "servers": [
{ {