UX Pass 4 (#3120)
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
4
.gitignore
vendored
@ -524,6 +524,7 @@ UI/Web/dist/
|
||||
/API/config/Hangfire.db
|
||||
/API/config/Hangfire-log.db
|
||||
API/config/covers/
|
||||
API/config/images/*
|
||||
API/config/stats/*
|
||||
API/config/stats/app_stats.json
|
||||
API/config/pre-metadata/
|
||||
@ -539,3 +540,6 @@ BenchmarkDotNet.Artifacts
|
||||
API.Tests/Services/Test Data/ImageService/**/*_output*
|
||||
API.Tests/Services/Test Data/ImageService/**/*_baseline*
|
||||
API.Tests/Services/Test Data/ImageService/**/*.html
|
||||
|
||||
|
||||
API.Tests/Services/Test Data/ScannerService/ScanTests/**/*
|
||||
|
@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
|
||||
|
@ -25,13 +25,21 @@ using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
internal class MockReadingItemService : IReadingItemService
|
||||
public class MockReadingItemService : IReadingItemService
|
||||
{
|
||||
private readonly IDefaultParser _defaultParser;
|
||||
private readonly BasicParser _basicParser;
|
||||
private readonly ComicVineParser _comicVineParser;
|
||||
private readonly ImageParser _imageParser;
|
||||
private readonly BookParser _bookParser;
|
||||
private readonly PdfParser _pdfParser;
|
||||
|
||||
public MockReadingItemService(IDefaultParser defaultParser)
|
||||
public MockReadingItemService(IDirectoryService directoryService, IBookService bookService)
|
||||
{
|
||||
_defaultParser = defaultParser;
|
||||
_imageParser = new ImageParser(directoryService);
|
||||
_basicParser = new BasicParser(directoryService, _imageParser);
|
||||
_bookParser = new BookParser(directoryService, bookService, _basicParser);
|
||||
_comicVineParser = new ComicVineParser(directoryService);
|
||||
_pdfParser = new PdfParser(directoryService);
|
||||
}
|
||||
|
||||
public ComicInfo GetComicInfo(string filePath)
|
||||
@ -56,12 +64,33 @@ internal class MockReadingItemService : IReadingItemService
|
||||
|
||||
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
|
||||
{
|
||||
return _defaultParser.Parse(path, rootPath, libraryRoot, type);
|
||||
if (_comicVineParser.IsApplicable(path, type))
|
||||
{
|
||||
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
|
||||
}
|
||||
if (_imageParser.IsApplicable(path, type))
|
||||
{
|
||||
return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
|
||||
}
|
||||
if (_bookParser.IsApplicable(path, type))
|
||||
{
|
||||
return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
|
||||
}
|
||||
if (_pdfParser.IsApplicable(path, type))
|
||||
{
|
||||
return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
|
||||
}
|
||||
if (_basicParser.IsApplicable(path, type))
|
||||
{
|
||||
return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
|
||||
{
|
||||
return _defaultParser.Parse(path, rootPath, libraryRoot, type);
|
||||
return Parse(path, rootPath, libraryRoot, type);
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,7 +204,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
|
||||
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
|
||||
|
||||
// var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
|
||||
//
|
||||
@ -239,7 +268,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
||||
var fileSystem = CreateTestFilesystem();
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
|
||||
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
|
||||
|
||||
var directoriesSeen = new HashSet<string>();
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||
@ -259,7 +288,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
||||
var fileSystem = CreateTestFilesystem();
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
|
||||
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
|
||||
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||
@ -294,7 +323,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
|
||||
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
|
||||
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||
@ -323,7 +352,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
||||
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
|
||||
new MockReadingItemService(ds, Substitute.For<IBookService>()), Substitute.For<IEventHub>());
|
||||
|
||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||
|
@ -1,20 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using API.Tests.Helpers;
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class ScannerServiceTests
|
||||
public class ScannerServiceTests : AbstractDbTest
|
||||
{
|
||||
private readonly ITestOutputHelper _testOutputHelper;
|
||||
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/ScanTests");
|
||||
private readonly string _testcasesDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/TestCases");
|
||||
private readonly string _imagePath = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/1x1.png");
|
||||
|
||||
public ScannerServiceTests(ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
_testOutputHelper = testOutputHelper;
|
||||
|
||||
// Set up Hangfire to use in-memory storage for testing
|
||||
GlobalConfiguration.Configuration.UseInMemoryStorage();
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
_context.Library.RemoveRange(_context.Library);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindSeriesNotOnDisk_Should_Remove1()
|
||||
{
|
||||
@ -68,4 +103,182 @@ public class ScannerServiceTests
|
||||
|
||||
Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanLibrary_ComicVine_PublisherFolder()
|
||||
{
|
||||
var testcase = "Publisher - ComicVine.json";
|
||||
var postLib = await GenerateScannerData(testcase);
|
||||
|
||||
Assert.NotNull(postLib);
|
||||
Assert.Equal(4, postLib.Series.Count);
|
||||
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
private async Task<Library> GenerateScannerData(string testcase)
|
||||
{
|
||||
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase));
|
||||
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectoryPath}");
|
||||
|
||||
var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase));
|
||||
|
||||
var library = new LibraryBuilder(publisher, type)
|
||||
.WithFolders([new FolderPath() {Path = testDirectoryPath}])
|
||||
.Build();
|
||||
|
||||
var admin = new AppUserBuilder("admin", "admin@kavita.com", Seed.DefaultThemes[0])
|
||||
.WithLibrary(library)
|
||||
.Build();
|
||||
|
||||
_unitOfWork.UserRepository.Add(admin); // Admin is needed for generating collections/reading lists
|
||||
_unitOfWork.LibraryRepository.Add(library);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
|
||||
var mockReadingService = new MockReadingItemService(ds, Substitute.For<IBookService>());
|
||||
var processSeries = new ProcessSeries(_unitOfWork, Substitute.For<ILogger<ProcessSeries>>(),
|
||||
Substitute.For<IEventHub>(),
|
||||
ds, Substitute.For<ICacheHelper>(), mockReadingService, Substitute.For<IFileService>(),
|
||||
Substitute.For<IMetadataService>(),
|
||||
Substitute.For<IWordCountAnalyzerService>(), Substitute.For<ICollectionTagService>(),
|
||||
Substitute.For<IReadingListService>(),
|
||||
Substitute.For<IExternalMetadataService>(), new TagManagerService(_unitOfWork, Substitute.For<ILogger<TagManagerService>>()));
|
||||
|
||||
var scanner = new ScannerService(_unitOfWork, Substitute.For<ILogger<ScannerService>>(),
|
||||
Substitute.For<IMetadataService>(),
|
||||
Substitute.For<ICacheService>(), Substitute.For<IEventHub>(), ds,
|
||||
mockReadingService, processSeries, Substitute.For<IWordCountAnalyzerService>());
|
||||
|
||||
await scanner.ScanLibrary(library.Id);
|
||||
|
||||
var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series);
|
||||
return postLib;
|
||||
}
|
||||
|
||||
private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input)
|
||||
{
|
||||
// Split the input string based on " - "
|
||||
var parts = input.Split(" - ", StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
throw new ArgumentException("Input must be in the format 'Publisher - LibraryType'");
|
||||
}
|
||||
|
||||
var publisher = parts[0].Trim();
|
||||
var libraryTypeString = parts[1].Trim();
|
||||
|
||||
// Try to parse the right-hand side as a LibraryType enum
|
||||
if (!Enum.TryParse<LibraryType>(libraryTypeString, out var libraryType))
|
||||
{
|
||||
throw new ArgumentException($"'{libraryTypeString}' is not a valid LibraryType");
|
||||
}
|
||||
|
||||
return (publisher, libraryType);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<string> GenerateTestDirectory(string mapPath)
|
||||
{
|
||||
// Read the map file
|
||||
var mapContent = await File.ReadAllTextAsync(mapPath);
|
||||
|
||||
// Deserialize the JSON content into a list of strings using System.Text.Json
|
||||
var filePaths = JsonSerializer.Deserialize<List<string>>(mapContent);
|
||||
|
||||
// Create a test directory
|
||||
var testDirectory = Path.Combine(_testDirectory, Path.GetFileNameWithoutExtension(mapPath));
|
||||
if (Directory.Exists(testDirectory))
|
||||
{
|
||||
Directory.Delete(testDirectory, true);
|
||||
}
|
||||
Directory.CreateDirectory(testDirectory);
|
||||
|
||||
// Generate the files and folders
|
||||
await Scaffold(testDirectory, filePaths);
|
||||
|
||||
return testDirectory;
|
||||
}
|
||||
|
||||
|
||||
private async Task Scaffold(string testDirectory, List<string> filePaths)
|
||||
{
|
||||
foreach (var relativePath in filePaths)
|
||||
{
|
||||
var fullPath = Path.Combine(testDirectory, relativePath);
|
||||
var fileDir = Path.GetDirectoryName(fullPath);
|
||||
|
||||
// Create the directory if it doesn't exist
|
||||
if (!Directory.Exists(fileDir))
|
||||
{
|
||||
Directory.CreateDirectory(fileDir);
|
||||
Console.WriteLine($"Created directory: {fileDir}");
|
||||
}
|
||||
|
||||
var ext = Path.GetExtension(fullPath).ToLower();
|
||||
if (new[] { ".cbz", ".cbr", ".zip", ".rar" }.Contains(ext))
|
||||
{
|
||||
CreateMinimalCbz(fullPath, includeMetadata: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create an empty file
|
||||
await File.Create(fullPath).DisposeAsync();
|
||||
Console.WriteLine($"Created empty file: {fullPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateMinimalCbz(string filePath, bool includeMetadata)
|
||||
{
|
||||
var tempImagePath = _imagePath; // Assuming _imagePath is a valid path to the 1x1 image
|
||||
|
||||
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
|
||||
{
|
||||
// Add the 1x1 image to the archive
|
||||
archive.CreateEntryFromFile(tempImagePath, "1x1.png");
|
||||
|
||||
if (includeMetadata)
|
||||
{
|
||||
var comicInfo = GenerateComicInfo();
|
||||
var entry = archive.CreateEntry("ComicInfo.xml");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream, Encoding.UTF8);
|
||||
writer.Write(comicInfo);
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"Created minimal CBZ archive: {filePath} with{(includeMetadata ? "" : "out")} metadata.");
|
||||
}
|
||||
|
||||
private string GenerateComicInfo()
|
||||
{
|
||||
var comicInfo = new StringBuilder();
|
||||
comicInfo.AppendLine("<?xml version='1.0' encoding='utf-8'?>");
|
||||
comicInfo.AppendLine("<ComicInfo>");
|
||||
|
||||
// People Tags
|
||||
string[] people = { /* Your list of people here */ };
|
||||
string[] genres = { /* Your list of genres here */ };
|
||||
|
||||
void AddRandomTag(string tagName, string[] choices)
|
||||
{
|
||||
if (new Random().Next(0, 2) == 1) // 50% chance to include the tag
|
||||
{
|
||||
var selected = choices.OrderBy(x => Guid.NewGuid()).Take(new Random().Next(1, 5)).ToArray();
|
||||
comicInfo.AppendLine($" <{tagName}>{string.Join(", ", selected)}</{tagName}>");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var tag in new[] { "Writer", "Penciller", "Inker", "CoverArtist", "Publisher", "Character", "Imprint", "Colorist", "Letterer", "Editor", "Translator", "Team", "Location" })
|
||||
{
|
||||
AddRandomTag(tag, people);
|
||||
}
|
||||
|
||||
AddRandomTag("Genre", genres);
|
||||
comicInfo.AppendLine("</ComicInfo>");
|
||||
|
||||
return comicInfo.ToString();
|
||||
}
|
||||
}
|
||||
|
BIN
API.Tests/Services/Test Data/ScannerService/1x1.png
Normal file
After Width: | Height: | Size: 69 B |
@ -1,5 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<ComicInfo>
|
||||
<Series>Accel World</Series>
|
||||
<Number>2</Number>
|
||||
</ComicInfo>
|
@ -1,6 +0,0 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<ComicInfo>
|
||||
<Series>Hajime no Ippo</Series>
|
||||
<Number>3</Number>
|
||||
<AgeRating>M</AgeRating>
|
||||
</ComicInfo>
|
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
@ -1 +0,0 @@
|
||||
This is an example of a layout. All files in here have non-copyrighted data but emulate real series to ensure the Process series Works as expected.
|
@ -0,0 +1,22 @@
|
||||
[
|
||||
"Antarctic Press/Plush (2018)/Plush 002 (2019).cbz",
|
||||
"Antarctic Press/Plush (2018)/Plush 001 (2018).cbz",
|
||||
"12-Gauge Comics/Plush (2022)/Plush 1 (2022).cbz",
|
||||
"12-Gauge Comics/Plush (2022)/Plush 2 (2022).cbz",
|
||||
"12-Gauge Comics/Plush (2022)/Plush 3 (2023).cbz",
|
||||
"12-Gauge Comics/Plush (2022)/Plush 004 (2023).cbz",
|
||||
"12-Gauge Comics/Plush (2022)/Plush 005 (2023).cbz",
|
||||
"12-Gauge Comics/Plush (2022)/Plush 006 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 009 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 1 (2022).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 2 (2022).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 3 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 004 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 005 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 006 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 007 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 008 (2023).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 010 (2024).cbz",
|
||||
"Ablaze/Traveling to Mars (2022)/Traveling to Mars 011 (2024).cbz",
|
||||
"Blood Hunters V2024 (2024)/Blood Hunters 001 (2024).cbz"
|
||||
]
|
@ -56,7 +56,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
<PackageReference Include="MailKit" Version="4.7.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@ -70,20 +70,20 @@
|
||||
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.62" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.63" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||
<PackageReference Include="NetVips" Version="2.4.1" />
|
||||
<PackageReference Include="NetVips.Native" Version="8.15.2" />
|
||||
<PackageReference Include="NetVips.Native" Version="8.15.3" />
|
||||
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
|
||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
@ -96,15 +96,15 @@
|
||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.7" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="8.0.8" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -34,7 +34,7 @@ public class CblController : BaseApiController
|
||||
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("validate")]
|
||||
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromForm] bool comicVineMatching = false)
|
||||
public async Task<ActionResult<CblImportSummaryDto>> ValidateCbl(IFormFile cbl, [FromQuery] bool comicVineMatching = false)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
try
|
||||
@ -85,7 +85,7 @@ public class CblController : BaseApiController
|
||||
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("import")]
|
||||
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromForm] bool dryRun = false, [FromForm] bool comicVineMatching = false)
|
||||
public async Task<ActionResult<CblImportSummaryDto>> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool comicVineMatching = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -242,6 +242,46 @@ public class ImageController : BaseApiController
|
||||
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the image associated with a publisher
|
||||
/// </summary>
|
||||
/// <param name="publisherName"></param>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("publisher")]
|
||||
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Month, VaryByQueryKeys = ["publisherName", "apiKey"])]
|
||||
public async Task<ActionResult> GetPublisherImage(string publisherName, string apiKey)
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||
if (userId == 0) return BadRequest();
|
||||
if (string.IsNullOrEmpty(publisherName)) return BadRequest(await _localizationService.Translate(userId, "must-be-defined", "publisherName"));
|
||||
if (publisherName.Contains("..")) return BadRequest();
|
||||
|
||||
var encodeFormat = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EncodeMediaAs;
|
||||
|
||||
// Check if the domain exists
|
||||
var domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory, ImageService.GetPublisherFormat(publisherName, encodeFormat));
|
||||
if (!_directoryService.FileSystem.File.Exists(domainFilePath))
|
||||
{
|
||||
// We need to request the favicon and save it
|
||||
try
|
||||
{
|
||||
domainFilePath = _directoryService.FileSystem.Path.Join(_directoryService.PublisherDirectory,
|
||||
await _imageService.DownloadPublisherImageAsync(publisherName, encodeFormat));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(userId, "generic-favicon"));
|
||||
}
|
||||
}
|
||||
|
||||
var file = new FileInfo(domainFilePath);
|
||||
var format = Path.GetExtension(file.FullName);
|
||||
|
||||
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a temp coverupload image
|
||||
/// </summary>
|
||||
|
@ -34,5 +34,4 @@ public enum LibraryType
|
||||
/// </summary>
|
||||
[Description("Comic (Comic Vine)")]
|
||||
ComicVine = 5,
|
||||
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ public interface IDirectoryService
|
||||
string LocalizationDirectory { get; }
|
||||
string CustomizedTemplateDirectory { get; }
|
||||
string TemplateDirectory { get; }
|
||||
string PublisherDirectory { get; }
|
||||
/// <summary>
|
||||
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
|
||||
/// </summary>
|
||||
@ -88,6 +89,7 @@ public class DirectoryService : IDirectoryService
|
||||
public string LocalizationDirectory { get; }
|
||||
public string CustomizedTemplateDirectory { get; }
|
||||
public string TemplateDirectory { get; }
|
||||
public string PublisherDirectory { get; }
|
||||
private readonly ILogger<DirectoryService> _logger;
|
||||
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
||||
|
||||
@ -125,6 +127,8 @@ public class DirectoryService : IDirectoryService
|
||||
ExistOrCreate(CustomizedTemplateDirectory);
|
||||
TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates");
|
||||
ExistOrCreate(TemplateDirectory);
|
||||
PublisherDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "images", "publishers");
|
||||
ExistOrCreate(PublisherDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -3,6 +3,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
@ -45,7 +46,8 @@ public class StartupTasksHostedService : IHostedService
|
||||
if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching)
|
||||
{
|
||||
var libraryWatcher = scope.ServiceProvider.GetRequiredService<ILibraryWatcher>();
|
||||
await libraryWatcher.StartWatching();
|
||||
// Push this off for a bit for people with massive libraries, as it can take up to 45 mins and blocks the thread
|
||||
BackgroundJob.Enqueue(() => libraryWatcher.StartWatching());
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
@ -67,6 +67,7 @@ public interface IImageService
|
||||
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
||||
Task<bool> IsImage(string filePath);
|
||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||
void UpdateColorScape(IHasCoverImage entity);
|
||||
}
|
||||
|
||||
@ -380,7 +381,7 @@ public class ImageService : IImageService
|
||||
{
|
||||
if (string.IsNullOrEmpty(correctSizeLink))
|
||||
{
|
||||
correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl);
|
||||
correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl);
|
||||
}
|
||||
if (string.IsNullOrEmpty(correctSizeLink))
|
||||
{
|
||||
@ -424,11 +425,57 @@ public class ImageService : IImageService
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
|
||||
_logger.LogDebug("Favicon for {Domain} downloaded and saved successfully", domain);
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
|
||||
_logger.LogError(ex, "Error downloading favicon for {Domain}", domain);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat)
|
||||
{
|
||||
try
|
||||
{
|
||||
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
|
||||
if (string.IsNullOrEmpty(publisherLink))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
var finalUrl = publisherLink;
|
||||
|
||||
_logger.LogTrace("Fetching publisher image from {Url}", finalUrl);
|
||||
// Download the favicon.ico file using Flurl
|
||||
var publisherStream = await finalUrl
|
||||
.AllowHttpStatus("2xx,304")
|
||||
.GetStreamAsync();
|
||||
|
||||
// Create the destination file path
|
||||
using var image = Image.PngloadStream(publisherStream);
|
||||
var filename = GetPublisherFormat(publisherName, encodeFormat);
|
||||
switch (encodeFormat)
|
||||
{
|
||||
case EncodeFormat.PNG:
|
||||
image.Pngsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.WEBP:
|
||||
image.Webpsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
case EncodeFormat.AVIF:
|
||||
image.Heifsave(Path.Combine(_directoryService.PublisherDirectory, filename));
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
|
||||
}
|
||||
|
||||
|
||||
_logger.LogDebug("Publisher image for {PublisherName} downloaded and saved successfully", publisherName);
|
||||
return filename;
|
||||
} catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error downloading image for {PublisherName}", publisherName);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@ -565,18 +612,12 @@ public class ImageService : IImageService
|
||||
return centroids;
|
||||
}
|
||||
|
||||
// public static Vector3 GetComplementaryColor(Vector3 color)
|
||||
// {
|
||||
// // Simple complementary color calculation
|
||||
// return new Vector3(255 - color.X, 255 - color.Y, 255 - color.Z);
|
||||
// }
|
||||
|
||||
public static List<Vector3> SortByBrightness(List<Vector3> colors)
|
||||
{
|
||||
return colors.OrderBy(c => 0.299 * c.X + 0.587 * c.Y + 0.114 * c.Z).ToList();
|
||||
}
|
||||
|
||||
public static List<Vector3> SortByVibrancy(List<Vector3> colors)
|
||||
private static List<Vector3> SortByVibrancy(List<Vector3> colors)
|
||||
{
|
||||
return colors.OrderByDescending(c =>
|
||||
{
|
||||
@ -686,10 +727,10 @@ public class ImageService : IImageService
|
||||
};
|
||||
}
|
||||
|
||||
private static string FallbackToKavitaReaderFavicon(string baseUrl)
|
||||
private static async Task<string> FallbackToKavitaReaderFavicon(string baseUrl)
|
||||
{
|
||||
var correctSizeLink = string.Empty;
|
||||
var allOverrides = "https://kavitareader.com/assets/favicons/urls.txt".GetStringAsync().Result;
|
||||
var allOverrides = await "https://www.kavitareader.com/assets/favicons/urls.txt".GetStringAsync();
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||
@ -699,17 +740,51 @@ public class ImageService : IImageService
|
||||
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
||||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
||||
));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
||||
}
|
||||
|
||||
correctSizeLink = "https://kavitareader.com/assets/favicons/" + externalFile;
|
||||
correctSizeLink = "https://www.kavitareader.com/assets/favicons/" + externalFile;
|
||||
}
|
||||
|
||||
return correctSizeLink;
|
||||
}
|
||||
|
||||
private static async Task<string> FallbackToKavitaReaderPublisher(string publisherName)
|
||||
{
|
||||
var externalLink = string.Empty;
|
||||
var allOverrides = await "https://www.kavitareader.com/assets/publishers/publishers.txt".GetStringAsync();
|
||||
if (!string.IsNullOrEmpty(allOverrides))
|
||||
{
|
||||
var externalFile = allOverrides
|
||||
.Split("\n")
|
||||
.Select(publisherLine =>
|
||||
{
|
||||
var tokens = publisherLine.Split("|");
|
||||
if (tokens.Length != 2) return null;
|
||||
var aliases = tokens[0];
|
||||
// Multiple publisher aliases are separated by #
|
||||
if (aliases.Split("#").Any(name => name.ToLowerInvariant().Trim().Equals(publisherName.ToLowerInvariant().Trim())))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.FirstOrDefault(url => !string.IsNullOrEmpty(url));
|
||||
|
||||
if (string.IsNullOrEmpty(externalFile))
|
||||
{
|
||||
throw new KavitaException($"Could not grab publisher image for {publisherName}");
|
||||
}
|
||||
|
||||
externalLink = "https://www.kavitareader.com/assets/publishers/" + externalFile;
|
||||
}
|
||||
|
||||
return externalLink;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
|
||||
{
|
||||
@ -805,6 +880,11 @@ public class ImageService : IImageService
|
||||
return $"{new Uri(url).Host.Replace("www.", string.Empty)}{encodeFormat.GetExtension()}";
|
||||
}
|
||||
|
||||
public static string GetPublisherFormat(string publisher, EncodeFormat encodeFormat)
|
||||
{
|
||||
return $"{publisher}{encodeFormat.GetExtension()}";
|
||||
}
|
||||
|
||||
|
||||
public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
|
||||
{
|
||||
@ -891,4 +971,6 @@ public class ImageService : IImageService
|
||||
|
||||
return Color.FromArgb(r, g, b);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
$image-height: 230px;
|
||||
$image-height: 232.91px;
|
||||
$image-width: 160px;
|
||||
|
||||
.error-banner {
|
||||
@ -118,7 +118,7 @@ $image-width: 160px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 230px;
|
||||
height: 232.91px;
|
||||
transition: all 0.2s;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
|
@ -7,16 +7,16 @@ import {ScrobbleProvider} from "../_services/scrobbling.service";
|
||||
})
|
||||
export class ProviderImagePipe implements PipeTransform {
|
||||
|
||||
transform(value: ScrobbleProvider): string {
|
||||
transform(value: ScrobbleProvider, large: boolean = false): string {
|
||||
switch (value) {
|
||||
case ScrobbleProvider.AniList:
|
||||
return 'assets/images/ExternalServices/AniList.png';
|
||||
return `assets/images/ExternalServices/AniList${large ? '-lg' : ''}.png`;
|
||||
case ScrobbleProvider.Mal:
|
||||
return 'assets/images/ExternalServices/MAL.png';
|
||||
return `assets/images/ExternalServices/MAL${large ? '-lg' : ''}.png`;
|
||||
case ScrobbleProvider.GoogleBooks:
|
||||
return 'assets/images/ExternalServices/GoogleBooks.png';
|
||||
return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`;
|
||||
case ScrobbleProvider.Kavita:
|
||||
return 'assets/images/logo-32.png';
|
||||
return `assets/images/logo-${large ? '64' : '32'}.png`;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -556,7 +556,7 @@ export class ActionService {
|
||||
if (this.collectionModalRef != null) { return; }
|
||||
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' });
|
||||
this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id);
|
||||
this.collectionModalRef.componentInstance.title = translate('action.new-collection');
|
||||
this.collectionModalRef.componentInstance.title = translate('actionable.new-collection');
|
||||
|
||||
this.collectionModalRef.closed.pipe(take(1)).subscribe(() => {
|
||||
this.collectionModalRef = null;
|
||||
|
@ -217,14 +217,22 @@ export class ColorscapeService {
|
||||
private setColorsImmediately(colors: ColorSpaceRGBA) {
|
||||
this.injectStyleElement(colorScapeSelector, `
|
||||
:root, :root .default {
|
||||
--colorscape-primary-color: ${this.rgbaToString(colors.primary)};
|
||||
--colorscape-lighter-color: ${this.rgbaToString(colors.lighter)};
|
||||
--colorscape-darker-color: ${this.rgbaToString(colors.darker)};
|
||||
--colorscape-complementary-color: ${this.rgbaToString(colors.complementary)};
|
||||
--colorscape-primary-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })};
|
||||
--colorscape-lighter-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })};
|
||||
--colorscape-darker-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })};
|
||||
--colorscape-complementary-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })};
|
||||
--colorscape-primary-color: ${this.rgbToString(colors.primary)};
|
||||
--colorscape-lighter-color: ${this.rgbToString(colors.lighter)};
|
||||
--colorscape-darker-color: ${this.rgbToString(colors.darker)};
|
||||
--colorscape-complementary-color: ${this.rgbToString(colors.complementary)};
|
||||
--colorscape-primary-no-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })};
|
||||
--colorscape-lighter-no-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })};
|
||||
--colorscape-darker-no-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })};
|
||||
--colorscape-complementary-no-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0 })};
|
||||
--colorscape-primary-full-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 1 })};
|
||||
--colorscape-lighter-full-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 1 })};
|
||||
--colorscape-darker-full-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 1 })};
|
||||
--colorscape-complementary-full-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 1 })};
|
||||
--colorscape-primary-half-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0.5 })};
|
||||
--colorscape-lighter-half-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0.5 })};
|
||||
--colorscape-darker-half-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0.5 })};
|
||||
--colorscape-complementary-half-alpha-color: ${this.rgbaToString({ ...colors.complementary, a: 0.5 })};
|
||||
}
|
||||
`);
|
||||
}
|
||||
@ -362,6 +370,10 @@ export class ColorscapeService {
|
||||
return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`;
|
||||
}
|
||||
|
||||
private rgbToString(color: RGBAColor): string {
|
||||
return `rgb(${color.r}, ${color.g}, ${color.b})`;
|
||||
}
|
||||
|
||||
private getCssVariable(variableName: string): string {
|
||||
return getComputedStyle(this.document.body).getPropertyValue(variableName).trim();
|
||||
}
|
||||
|
@ -91,6 +91,10 @@ export class ImageService {
|
||||
return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`;
|
||||
}
|
||||
|
||||
getPublisherImage(name: string) {
|
||||
return `${this.baseUrl}image/publisher?publisherName=${encodeURIComponent(name)}&apiKey=${this.encodedKey}`;
|
||||
}
|
||||
|
||||
getCoverUploadImage(filename: string) {
|
||||
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
|
||||
}
|
||||
|
@ -106,12 +106,12 @@ export class ReadingListService {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'readinglist/name-exists?name=' + name);
|
||||
}
|
||||
|
||||
validateCbl(form: FormData) {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/validate', form);
|
||||
validateCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/validate?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
|
||||
}
|
||||
|
||||
importCbl(form: FormData) {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/import', form);
|
||||
importCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) {
|
||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
|
||||
}
|
||||
|
||||
getCharacters(readingListId: number) {
|
||||
|
@ -20,6 +20,17 @@
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="webLinks" [title]="t('weblinks-title')">
|
||||
<ng-template #carouselItem let-item>
|
||||
<a class="me-1" [href]="item | safeHtml" target="_blank" rel="noopener noreferrer" [title]="item">
|
||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(item)"
|
||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||
</a>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
@if (genres.length > 0 || tags.length > 0) {
|
||||
<div class="setting-section-break" aria-hidden="true"></div>
|
||||
}
|
||||
|
@ -11,30 +11,37 @@ import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.se
|
||||
import {Genre} from "../../_models/metadata/genre";
|
||||
import {Tag} from "../../_models/tag";
|
||||
import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-details-tab',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CarouselReelComponent,
|
||||
PersonBadgeComponent,
|
||||
TranslocoDirective,
|
||||
TagBadgeComponent
|
||||
],
|
||||
imports: [
|
||||
CarouselReelComponent,
|
||||
PersonBadgeComponent,
|
||||
TranslocoDirective,
|
||||
TagBadgeComponent,
|
||||
ImageComponent,
|
||||
SafeHtmlPipe
|
||||
],
|
||||
templateUrl: './details-tab.component.html',
|
||||
styleUrl: './details-tab.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DetailsTabComponent {
|
||||
|
||||
private readonly router = inject(Router);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
protected readonly PersonRole = PersonRole;
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
@Input({required: true}) metadata!: IHasCast;
|
||||
@Input() genres: Array<Genre> = [];
|
||||
@Input() tags: Array<Tag> = [];
|
||||
@Input() webLinks: Array<string> = [];
|
||||
|
||||
|
||||
openPerson(queryParamName: FilterField, filter: Person) {
|
||||
|
@ -10,7 +10,7 @@
|
||||
<div class="offcanvas-body">
|
||||
<ng-container *ngIf="CoverUrl as coverUrl">
|
||||
<div style="width: 160px" class="mx-auto mb-3">
|
||||
<app-image *ngIf="coverUrl" height="230px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '230px'}" [imageUrl]="coverUrl"></app-image>
|
||||
<app-image *ngIf="coverUrl" height="232.91px" width="160px" [styles]="{'object-fit': 'contain', 'max-height': '232.91px'}" [imageUrl]="coverUrl"></app-image>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
@ -46,16 +46,16 @@
|
||||
.default-background {
|
||||
background: radial-gradient(circle farthest-side at 0% 100%,
|
||||
var(--colorscape-darker-color) 0%,
|
||||
var(--colorscape-darker-alpha-color) 100%),
|
||||
var(--colorscape-darker-no-alpha-color) 100%),
|
||||
radial-gradient(circle farthest-side at 100% 100%,
|
||||
var(--colorscape-primary-color) 0%,
|
||||
var(--colorscape-primary-alpha-color) 100%),
|
||||
var(--colorscape-primary-no-alpha-color) 100%),
|
||||
radial-gradient(circle farthest-side at 100% 0%,
|
||||
var(--colorscape-lighter-color) 0%,
|
||||
var(--colorscape-lighter-alpha-color) 100%),
|
||||
var(--colorscape-lighter-no-alpha-color) 100%),
|
||||
radial-gradient(circle farthest-side at 0% 0%,
|
||||
var(--colorscape-complementary-color) 0%,
|
||||
var(--colorscape-complementary-alpha-color) 100%),
|
||||
var(--colorscape-complementary-no-alpha-color) 100%),
|
||||
var(--bs-body-bg);
|
||||
}
|
||||
|
||||
@ -68,6 +68,9 @@
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-color: #121212;
|
||||
filter: blur(20px);
|
||||
object-fit: contain;
|
||||
transform: scale(1.1);
|
||||
|
||||
.background-area {
|
||||
position: absolute;
|
||||
|
@ -97,6 +97,7 @@ export class AppComponent implements OnInit {
|
||||
// Sets a CSS variable for the actual device viewport height. Needed for mobile dev.
|
||||
const vh = window.innerHeight * 0.01;
|
||||
this.document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
this.utilityService.activeBreakpointSource.next(this.utilityService.getActiveBreakpoint());
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -146,7 +146,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
||||
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
|
||||
.filter(item => item.action !== Action.Edit);
|
||||
this.chapterActions.push({title: 'read', description: 'read-tooltip', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
|
||||
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);
|
||||
|
@ -1,4 +1,4 @@
|
||||
$image-height: 230px;
|
||||
$image-height: 232.91px;
|
||||
$image-width: 160px;
|
||||
|
||||
.card-img-top {
|
||||
|
@ -37,11 +37,19 @@
|
||||
}
|
||||
|
||||
@case (LibraryType.Book) {
|
||||
{{volumeTitle}}
|
||||
@if (titleName !== '' && prioritizeTitleName) {
|
||||
{{titleName}}
|
||||
} @else {
|
||||
{{volumeTitle}}
|
||||
}
|
||||
}
|
||||
|
||||
@case (LibraryType.LightNovel) {
|
||||
{{volumeTitle}}
|
||||
@if (titleName !== '' && prioritizeTitleName) {
|
||||
{{titleName}}
|
||||
} @else {
|
||||
{{volumeTitle}}
|
||||
}
|
||||
}
|
||||
|
||||
@case (LibraryType.Images) {
|
||||
|
@ -227,7 +227,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
this.scanLibrary(series);
|
||||
break;
|
||||
case(Action.RefreshMetadata):
|
||||
this.refreshMetadata(series);
|
||||
this.refreshMetadata(series, true);
|
||||
break;
|
||||
case(Action.GenerateColorScape):
|
||||
this.refreshMetadata(series, false);
|
||||
|
@ -60,7 +60,7 @@
|
||||
|
||||
<div class="card-title-container">
|
||||
<span class="card-title" id="{{volume.id}}" tabindex="0">
|
||||
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/chapter/{{volume.id}}">
|
||||
<a class="dark-exempt btn-icon" routerLink="/library/{{libraryId}}/series/{{seriesId}}/volume/{{volume.id}}">
|
||||
{{volume.name}}
|
||||
</a>
|
||||
</span>
|
||||
|
@ -7,7 +7,7 @@
|
||||
<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]="coverImage"></app-image>
|
||||
@if (chapter.pagesRead < chapter.pages && hasReadingProgress) {
|
||||
@if (chapter.pagesRead < chapter.pages && chapter.pagesRead > 0) {
|
||||
<div class="progress-banner" 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>
|
||||
@ -41,7 +41,7 @@
|
||||
|
||||
<app-metadata-detail-row [entity]="chapter"
|
||||
[ageRating]="chapter.ageRating"
|
||||
[hasReadingProgress]="hasReadingProgress"
|
||||
[hasReadingProgress]="chapter.pagesRead > 0"
|
||||
[readingTimeEntity]="chapter"
|
||||
[libraryType]="libraryType">
|
||||
</app-metadata-detail-row>
|
||||
@ -64,8 +64,8 @@
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary-outline" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
||||
<i class="fa {{chapter.pagesRead > 0 ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(chapter.pagesRead > 0) ? t('continue') : t('read')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
@ -74,7 +74,7 @@
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
<span>
|
||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||
<span class="read-btn--text"> {{(chapter.pagesRead > 0) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -100,7 +100,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<app-read-more [text]="chapter.summary || ''"></app-read-more>
|
||||
<app-read-more [text]="chapter.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 585 : 250"></app-read-more>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
@ -111,9 +111,6 @@
|
||||
<app-badge-expander [items]="chapter.writers">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
@ -124,9 +121,6 @@
|
||||
<app-badge-expander [items]="chapter.coverArtists">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
@ -75,6 +75,7 @@ import {DownloadButtonComponent} from "../series-detail/_components/download-but
|
||||
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";
|
||||
|
||||
enum TabID {
|
||||
Related = 'related-tab',
|
||||
@ -156,6 +157,7 @@ export class ChapterDetailComponent implements OnInit {
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly readingListService = inject(ReadingListService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
|
||||
|
||||
protected readonly AgeRating = AgeRating;
|
||||
@ -316,4 +318,5 @@ export class ChapterDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
protected readonly TabId = TabId;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe";
|
||||
import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe";
|
||||
import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload";
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||
import {NgForOf, NgIf, NgTemplateOutlet} from "@angular/common";
|
||||
import {NgTemplateOutlet} from "@angular/common";
|
||||
import {
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton,
|
||||
@ -11,7 +11,6 @@ import {
|
||||
NgbAccordionDirective,
|
||||
NgbAccordionHeader,
|
||||
NgbAccordionItem,
|
||||
NgbActiveModal
|
||||
} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||
import {StepTrackerComponent, TimelineStep} from "../step-tracker/step-tracker.component";
|
||||
@ -133,7 +132,7 @@ export class ImportCblComponent {
|
||||
formData.append('cbl', files[i]);
|
||||
formData.append('dryRun', 'true');
|
||||
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
|
||||
pages.push(this.readingListService.validateCbl(formData));
|
||||
pages.push(this.readingListService.validateCbl(formData, true, this.cblSettingsForm.get('comicVineMatching')?.value as boolean));
|
||||
}
|
||||
|
||||
forkJoin(pages).subscribe(results => {
|
||||
@ -225,7 +224,7 @@ export class ImportCblComponent {
|
||||
formData.append('cbl', files[i]);
|
||||
formData.append('dryRun', 'true');
|
||||
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
|
||||
pages.push(this.readingListService.importCbl(formData));
|
||||
pages.push(this.readingListService.importCbl(formData, true, this.cblSettingsForm.get('comicVineMatching')?.value as boolean));
|
||||
}
|
||||
forkJoin(pages).subscribe(results => {
|
||||
results.forEach(cblImport => {
|
||||
@ -250,7 +249,7 @@ export class ImportCblComponent {
|
||||
formData.append('cbl', files[i]);
|
||||
formData.append('dryRun', 'false');
|
||||
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
|
||||
pages.push(this.readingListService.importCbl(formData));
|
||||
pages.push(this.readingListService.importCbl(formData, false, this.cblSettingsForm.get('comicVineMatching')?.value as boolean));
|
||||
}
|
||||
|
||||
forkJoin(pages).subscribe(results => {
|
||||
|
@ -117,7 +117,8 @@
|
||||
<h5>{{t('characters-title')}}</h5>
|
||||
<app-badge-expander [items]="characters">
|
||||
<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>
|
||||
<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>
|
||||
|
@ -1,29 +1,29 @@
|
||||
<ng-container *transloco="let t; read: 'external-rating'">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
|
||||
popoverTitle="Your Rating + Overall" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
|
||||
<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}}
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
[popoverTitle]="t('kavita-tooltip')" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
|
||||
<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}}
|
||||
} @else {
|
||||
N/A
|
||||
}
|
||||
|
||||
@if (overallRating > 0) {
|
||||
+ {{overallRating}}
|
||||
}
|
||||
@if (hasUserRated || overallRating > 0) {
|
||||
%
|
||||
}
|
||||
</span>
|
||||
@if (overallRating > 0) {
|
||||
+ {{overallRating}}
|
||||
}
|
||||
@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" width="24" height="24" alt="" aria-hidden="true">
|
||||
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
|
||||
{{rating.averageScore}}%
|
||||
</span>
|
||||
</div>
|
||||
@ -32,6 +32,15 @@
|
||||
<div class="col-auto" style="padding-top: 8px">
|
||||
<app-loading [loading]="isLoading" size="spinner-border-sm"></app-loading>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2">
|
||||
@for(link of webLinks; track link) {
|
||||
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #popContent>
|
||||
|
@ -20,11 +20,13 @@ import {ThemeService} from "../../../_services/theme.service";
|
||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-rating',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective],
|
||||
imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective, SafeHtmlPipe],
|
||||
templateUrl: './external-rating.component.html',
|
||||
styleUrls: ['./external-rating.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -37,6 +39,8 @@ export class ExternalRatingComponent implements OnInit {
|
||||
private readonly themeService = inject(ThemeService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
public readonly imageService = inject(ImageService);
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
@Input({required: true}) seriesId!: number;
|
||||
@ -44,6 +48,7 @@ export class ExternalRatingComponent implements OnInit {
|
||||
@Input({required: true}) hasUserRated!: boolean;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
@Input({required: true}) ratings: Array<Rating> = [];
|
||||
@Input() webLinks: Array<string> = [];
|
||||
|
||||
isLoading: boolean = false;
|
||||
overallRating: number = -1;
|
||||
|
@ -1,28 +1,32 @@
|
||||
<ng-container *transloco="let t; read: 'series-detail'">
|
||||
<div class="mt-2 mb-2">
|
||||
@if (entity.publishers.length > 0) {
|
||||
<span class="me-2">{{entity.publishers[0].name}}</span>
|
||||
<div class="publisher-img-container d-inline-flex align-items-center me-2 position-relative">
|
||||
<app-image [imageUrl]="imageService.getPublisherImage(entity.publishers[0].name)" [classes]="'me-2'" [hideOnError]="true" width="32px" height="32px"
|
||||
aria-hidden="true"></app-image>
|
||||
<div class="position-relative d-inline-block" (click)="openGeneric(FilterField.Publisher, entity.publishers[0].id)">{{entity.publishers[0].name}}</div>
|
||||
</div>
|
||||
}
|
||||
<span class="me-2">
|
||||
<app-age-rating-image [rating]="ageRating"></app-age-rating-image>
|
||||
</span>
|
||||
|
||||
@if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) {
|
||||
<span class="word-count me-3">{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
|
||||
} @else {
|
||||
<span class="word-count me-3">{{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}}</span>
|
||||
}
|
||||
|
||||
@if (hasReadingProgress && readingTimeLeft && readingTimeLeft.avgHours !== 0) {
|
||||
<span [ngbTooltip]="t('time-left-alt')">
|
||||
<i class="fa-solid fa-clock me-1" aria-hidden="true"></i>
|
||||
<span class="time-left" [ngbTooltip]="t('time-left-alt')">
|
||||
<i class="fa-solid fa-clock" aria-hidden="true"></i>
|
||||
{{readingTimeLeft | readTimeLeft }}
|
||||
</span>
|
||||
} @else {
|
||||
<span [ngbTooltip]="t('time-to-read-alt')">
|
||||
<i class="fa-regular fa-clock me-1" aria-hidden="true"></i>
|
||||
<span class="time-left" [ngbTooltip]="t('time-to-read-alt')">
|
||||
<i class="fa-regular fa-clock" aria-hidden="true"></i>
|
||||
{{readingTimeEntity | readTime }}
|
||||
</span>
|
||||
}
|
||||
<span class="ms-2 me-2">•</span>
|
||||
@if (libraryType === LibraryType.Book || libraryType === LibraryType.LightNovel) {
|
||||
<span>{{t('words-count', {num: readingTimeEntity.wordCount | compactNumber})}}</span>
|
||||
} @else {
|
||||
<span>{{t('pages-count', {num: readingTimeEntity.pages | compactNumber})}}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -0,0 +1,20 @@
|
||||
.publisher-img-container {
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
font-size: 0.8rem;
|
||||
vertical-align: middle;
|
||||
|
||||
div {
|
||||
min-height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.time-left{
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.word-count {
|
||||
font-size: 0.8rem;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
|
||||
import {AgeRatingImageComponent} from "../../../_single-modules/age-rating-image/age-rating-image.component";
|
||||
import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe";
|
||||
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
|
||||
@ -10,6 +10,11 @@ import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {IHasReadingTime} from "../../../_models/common/i-has-reading-time";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {LibraryType} from "../../../_models/library/library";
|
||||
import {ImageComponent} from "../../../shared/image/image.component";
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-metadata-detail-row',
|
||||
@ -20,13 +25,18 @@ import {LibraryType} from "../../../_models/library/library";
|
||||
ReadTimeLeftPipe,
|
||||
ReadTimePipe,
|
||||
NgbTooltip,
|
||||
TranslocoDirective
|
||||
TranslocoDirective,
|
||||
ImageComponent
|
||||
],
|
||||
templateUrl: './metadata-detail-row.component.html',
|
||||
styleUrl: './metadata-detail-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class MetadataDetailRowComponent {
|
||||
protected readonly imageService = inject(ImageService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
|
||||
@Input({required: true}) entity!: IHasCast;
|
||||
@Input({required: true}) readingTimeEntity!: IHasReadingTime;
|
||||
@ -35,5 +45,11 @@ export class MetadataDetailRowComponent {
|
||||
@Input({required: true}) ageRating: AgeRating = AgeRating.Unknown;
|
||||
@Input({required: true}) libraryType!: LibraryType;
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
openGeneric(queryParamName: FilterField, filter: string | number) {
|
||||
if (queryParamName === FilterField.None) return;
|
||||
this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe();
|
||||
}
|
||||
|
||||
|
||||
protected readonly FilterField = FilterField;
|
||||
}
|
||||
|
@ -61,7 +61,8 @@
|
||||
[ratings]="ratings"
|
||||
[userRating]="series.userRating"
|
||||
[hasUserRated]="series.hasUserRated"
|
||||
[libraryType]="libraryType">
|
||||
[libraryType]="libraryType"
|
||||
[webLinks]="WebLinks">
|
||||
</app-external-rating>
|
||||
</div>
|
||||
|
||||
@ -71,9 +72,9 @@
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary-outline" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
||||
</span>
|
||||
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
<button type="button" class="btn btn-primary-outline dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
@ -127,89 +128,23 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 1000 : 250"></app-read-more>
|
||||
<app-read-more [text]="seriesMetadata.summary || ''" [maxLength]="(utilityService.activeBreakpoint$ | async)! >= Breakpoint.Desktop ? 585 : 200"></app-read-more>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<div class="mt-2 upper-details">
|
||||
<div class="row g-0">
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('writers-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="seriesMetadata.writers">
|
||||
<app-badge-expander [items]="seriesMetadata.writers"
|
||||
[itemsTillExpander]="3"
|
||||
[allowToggle]="false">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('cover-artists-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="seriesMetadata.coverArtists">
|
||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 mb-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('genres-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="seriesMetadata.genres" [itemsTillExpander]="5">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('tags-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="seriesMetadata.tags" [itemsTillExpander]="5">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-3 mb-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('weblinks-title')}}</span>
|
||||
<div>
|
||||
@for(link of WebLinks; track link) {
|
||||
<a class="me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||
<app-image height="24px" width="24px" aria-hidden="true" [imageUrl]="imageService.getWebLinkImage(link)"
|
||||
[errorImage]="imageService.errorWebLinkImage"></app-image>
|
||||
</a>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('publication-status-title')}}</span>
|
||||
<div>
|
||||
@ -225,6 +160,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 mb-2 upper-details">
|
||||
<div class="row g-0">
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('genres-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="seriesMetadata.genres"
|
||||
[itemsTillExpander]="3"
|
||||
[allowToggle]="false">
|
||||
<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>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-6">
|
||||
<span class="fw-bold">{{t('tags-title')}}</span>
|
||||
<div>
|
||||
<app-badge-expander [items]="seriesMetadata.tags"
|
||||
[itemsTillExpander]="3"
|
||||
[allowToggle]="false">
|
||||
<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>
|
||||
</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>
|
||||
@ -513,7 +510,7 @@
|
||||
<a ngbNavLink>{{t(TabID.Details)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabID.Details; prefetch on idle) {
|
||||
<app-details-tab [metadata]="seriesMetadata" [genres]="seriesMetadata.genres" [tags]="seriesMetadata.tags"></app-details-tab>
|
||||
<app-details-tab [metadata]="seriesMetadata" [genres]="seriesMetadata.genres" [tags]="seriesMetadata.tags" [webLinks]="WebLinks"></app-details-tab>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
@ -39,3 +39,14 @@
|
||||
background-color: var(--primary-color-dark-shade);
|
||||
}
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
}
|
||||
|
@ -580,7 +580,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.actionService.scanSeries(series);
|
||||
break;
|
||||
case(Action.RefreshMetadata):
|
||||
this.actionService.refreshSeriesMetadata(series);
|
||||
this.actionService.refreshSeriesMetadata(series, undefined, true);
|
||||
break;
|
||||
case(Action.GenerateColorScape):
|
||||
this.actionService.refreshSeriesMetadata(series, undefined, false);
|
||||
|
@ -6,7 +6,8 @@ import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { PaginatedResult } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {translate, TranslocoService} from "@jsverse/transloco";
|
||||
import {debounceTime, ReplaySubject, shareReplay} from "rxjs";
|
||||
|
||||
export enum KEY_CODES {
|
||||
RIGHT_ARROW = 'ArrowRight',
|
||||
@ -37,9 +38,10 @@ export enum Breakpoint {
|
||||
})
|
||||
export class UtilityService {
|
||||
|
||||
mangaFormatKeys: string[] = [];
|
||||
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
|
||||
public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true}));
|
||||
|
||||
constructor(private translocoService: TranslocoService) { }
|
||||
mangaFormatKeys: string[] = [];
|
||||
|
||||
|
||||
sortChapters = (a: Chapter, b: Chapter) => {
|
||||
@ -68,16 +70,16 @@ export class UtilityService {
|
||||
switch(libraryType) {
|
||||
case LibraryType.Book:
|
||||
case LibraryType.LightNovel:
|
||||
return this.translocoService.translate('common.book-num' + extra) + (includeSpace ? ' ' : '');
|
||||
return translate('common.book-num' + extra) + (includeSpace ? ' ' : '');
|
||||
case LibraryType.Comic:
|
||||
case LibraryType.ComicVine:
|
||||
if (includeHash) {
|
||||
return this.translocoService.translate('common.issue-hash-num');
|
||||
return translate('common.issue-hash-num');
|
||||
}
|
||||
return this.translocoService.translate('common.issue-num' + extra) + (includeSpace ? ' ' : '');
|
||||
return translate('common.issue-num' + extra) + (includeSpace ? ' ' : '');
|
||||
case LibraryType.Images:
|
||||
case LibraryType.Manga:
|
||||
return this.translocoService.translate('common.chapter-num' + extra) + (includeSpace ? ' ' : '');
|
||||
return translate('common.chapter-num' + extra) + (includeSpace ? ' ' : '');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,13 +3,16 @@
|
||||
<div class="content">
|
||||
@for(item of visibleItems; track item; let i = $index; let last = $last) {
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i, last: last }"></ng-container>
|
||||
@if (!last) {
|
||||
<span>, </span>
|
||||
}
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
@if (!isCollapsed && itemsLeft !== 0) {
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-2" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
|
||||
<a href="javascript:void(0);" type="button" class="dark-exempt btn-icon ms-1" (click)="toggleVisible()" [attr.aria-expanded]="!isCollapsed">
|
||||
{{t('more-items', {count: itemsLeft})}}
|
||||
</button>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -26,6 +26,7 @@ export class BadgeExpanderComponent implements OnInit {
|
||||
|
||||
@Input() items: Array<any> = [];
|
||||
@Input() itemsTillExpander: number = 4;
|
||||
@Input() allowToggle: boolean = true;
|
||||
@ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
|
||||
@ -42,6 +43,8 @@ export class BadgeExpanderComponent implements OnInit {
|
||||
}
|
||||
|
||||
toggleVisible() {
|
||||
if (!this.allowToggle) return;
|
||||
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.visibleItems = this.items;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -15,7 +15,6 @@ import {CoverUpdateEvent} from 'src/app/_models/events/cover-update-event';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {CommonModule, NgOptimizedImage} from "@angular/common";
|
||||
import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
|
||||
|
||||
/**
|
||||
@ -24,7 +23,7 @@ import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
|
||||
@Component({
|
||||
selector: 'app-image',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgOptimizedImage, LazyLoadImageModule],
|
||||
imports: [LazyLoadImageModule],
|
||||
templateUrl: './image.component.html',
|
||||
styleUrls: ['./image.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -62,10 +61,13 @@ export class ImageComponent implements OnChanges {
|
||||
*/
|
||||
@Input() styles: {[key: string]: string} = {};
|
||||
@Input() errorImage: string = this.imageService.errorImage;
|
||||
/**
|
||||
* If the image load fails, instead of showing an error image, hide the image (visibility)
|
||||
*/
|
||||
@Input() hideOnError: boolean = false;
|
||||
|
||||
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||
if (!this.processEvents) return;
|
||||
@ -138,6 +140,9 @@ export class ImageComponent implements OnChanges {
|
||||
// The image could not be loaded for some reason.
|
||||
// `event.data` is the error in this case
|
||||
this.renderer.removeClass(image, 'fade-in');
|
||||
if (this.hideOnError) {
|
||||
this.renderer.addClass(image, 'd-none');
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
break;
|
||||
case 'finally':
|
||||
|
@ -106,16 +106,22 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
new SideNavItem(SettingsTabId.General, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Statistics, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.System, [Role.Admin]),
|
||||
|
||||
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Libraries, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Tasks, [Role.Admin]),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'manage-section-title',
|
||||
title: 'import-section-title',
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Libraries, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.CBLImport, []),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'info-section-title',
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.System, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Statistics, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.MediaIssues, [Role.Admin],
|
||||
this.accountService.currentUser$.pipe(
|
||||
take(1),
|
||||
@ -132,13 +138,6 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
}
|
||||
})
|
||||
)),
|
||||
new SideNavItem(SettingsTabId.Tasks, [Role.Admin]),
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'import-section-title',
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.CBLImport, []),
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -7,7 +7,7 @@
|
||||
<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">
|
||||
|
||||
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px'}" [imageUrl]="coverImage"></app-image>
|
||||
@if (volume.pagesRead < volume.pages && hasReadingProgress) {
|
||||
@if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
|
||||
<div class="progress-banner" ngbTooltip="{{(volume.pagesRead / volume.pages) * 100 | number:'1.0-1'}}%">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="volume.pagesRead" [max]="volume.pages" [showValue]="true"></ngb-progressbar>
|
||||
</div>
|
||||
@ -20,13 +20,13 @@
|
||||
<div class="subtitle mt-2 mb-2">
|
||||
<span>
|
||||
{{t('volume-num')}}
|
||||
<app-entity-title [libraryType]="libraryType!" [entity]="volume" [prioritizeTitleName]="true"></app-entity-title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="volume" [prioritizeTitleName]="true"></app-entity-title>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<app-metadata-detail-row [entity]="volumeCast"
|
||||
[ageRating]="maxAgeRating"
|
||||
[hasReadingProgress]="hasReadingProgress"
|
||||
[hasReadingProgress]="volume.pagesRead > 0"
|
||||
[readingTimeEntity]="volume"
|
||||
[libraryType]="libraryType">
|
||||
</app-metadata-detail-row>
|
||||
@ -49,8 +49,8 @@
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary-outline" (click)="readVolume()">
|
||||
<span>
|
||||
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
||||
<i class="fa {{volume.pagesRead > 0 ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(volume.pagesRead > 0) ? t('continue') : t('read')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
@ -59,7 +59,7 @@
|
||||
<button ngbDropdownItem (click)="readVolume(true)">
|
||||
<span>
|
||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||
<span class="read-btn--text"> {{(volume.pagesRead > 0) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -85,7 +85,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<app-read-more [text]="volume.chapters[0].summary || ''"></app-read-more>
|
||||
<app-read-more [text]="volume.chapters[0].summary || ''" [maxLength]="utilityService.getActiveBreakpoint() >= Breakpoint.Desktop ? 585 : 250"></app-read-more>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
@ -96,9 +96,6 @@
|
||||
<app-badge-expander [items]="volumeCast.writers">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
@ -109,9 +106,6 @@
|
||||
<app-badge-expander [items]="volumeCast.coverArtists">
|
||||
<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>
|
||||
@if (!last) {
|
||||
,
|
||||
}
|
||||
</ng-template>
|
||||
</app-badge-expander>
|
||||
</div>
|
||||
|
@ -59,7 +59,7 @@ import {ImageComponent} from "../shared/image/image.component";
|
||||
import {CardItemComponent} from "../cards/card-item/card-item.component";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
|
||||
import {UtilityService} from "../shared/_services/utility.service";
|
||||
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
|
||||
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
|
||||
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
|
||||
import {
|
||||
@ -196,7 +196,6 @@ export class VolumeDetailComponent implements OnInit {
|
||||
volume: Volume | null = null;
|
||||
series: Series | null = null;
|
||||
libraryType: LibraryType | null = null;
|
||||
hasReadingProgress = false;
|
||||
activeTabId = TabID.Chapters;
|
||||
readingLists: ReadingList[] = [];
|
||||
|
||||
@ -458,4 +457,5 @@ export class VolumeDetailComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
}
|
||||
|
BIN
UI/Web/src/assets/images/ExternalServices/AniList-lg.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
UI/Web/src/assets/images/ExternalServices/GoogleBooks-lg.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
UI/Web/src/assets/images/ExternalServices/MAL-lg.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
UI/Web/src/assets/images/logo-64.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
@ -227,7 +227,7 @@
|
||||
"title": "Age Rating Restriction",
|
||||
"description": "When selected, all series and reading lists that have at least one item that is greater than the selected restriction will be pruned from results.",
|
||||
"not-applicable-for-admins": "This is not applicable for admins.",
|
||||
"age-rating-label": "Age Rating",
|
||||
"age-rating-label": "{{metadata-fields.age-rating-title}}",
|
||||
"no-restriction": "No Restriction",
|
||||
"include-unknowns-label": "Include Unknowns",
|
||||
"include-unknowns-tooltip": "If true, Unknowns will be allowed with Age Restriction. This could lead to untagged media leaking to users with Age restrictions."
|
||||
@ -848,7 +848,9 @@
|
||||
"publishers-title": "Publishers",
|
||||
"imprints-title": "Imprints",
|
||||
"teams-title": "Teams",
|
||||
"locations-title": "Locations"
|
||||
"locations-title": "Locations",
|
||||
"language-title": "Language",
|
||||
"age-rating-title": "Age Rating"
|
||||
},
|
||||
|
||||
"download-button": {
|
||||
@ -857,7 +859,8 @@
|
||||
},
|
||||
|
||||
"external-rating": {
|
||||
"entry-label": "See Details"
|
||||
"entry-label": "See Details",
|
||||
"kavita-tooltip": "Your Rating + Overall"
|
||||
},
|
||||
|
||||
"badge-expander": {
|
||||
@ -1052,21 +1055,22 @@
|
||||
},
|
||||
|
||||
"details-tab": {
|
||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
||||
"publishers-title": "{{series-metadata-detail.publishers-title}}",
|
||||
"characters-title": "{{series-metadata-detail.characters-title}}",
|
||||
"translators-title": "{{series-metadata-detail.translators-title}}",
|
||||
"letterers-title": "{{series-metadata-detail.letterers-title}}",
|
||||
"colorists-title": "{{series-metadata-detail.colorists-title}}",
|
||||
"inkers-title": "{{series-metadata-detail.inkers-title}}",
|
||||
"pencillers-title": "{{series-metadata-detail.pencillers-title}}",
|
||||
"cover-artists-title": "{{series-metadata-detail.cover-artists-title}}",
|
||||
"editors-title": "{{series-metadata-detail.editors-title}}",
|
||||
"teams-title": "{{series-metadata-detail.teams-title}}",
|
||||
"locations-title": "{{series-metadata-detail.locations-title}}",
|
||||
"imprints-title": "{{series-metadata-detail.imprints-title}}",
|
||||
"genres-title": "{{series-metadata-detail.genres-title}}",
|
||||
"tags-title": "{{series-metadata-detail.tags-title}}"
|
||||
"writers-title": "{{metadata-fields.writers-title}}",
|
||||
"publishers-title": "{{metadata-fields.publishers-title}}",
|
||||
"characters-title": "{{metadata-fields.characters-title}}",
|
||||
"translators-title": "{{metadata-fields.translators-title}}",
|
||||
"letterers-title": "{{metadata-fields.letterers-title}}",
|
||||
"colorists-title": "{{metadata-fields.colorists-title}}",
|
||||
"inkers-title": "{{metadata-fields.inkers-title}}",
|
||||
"pencillers-title": "{{metadata-fields.pencillers-title}}",
|
||||
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
|
||||
"editors-title": "{{metadata-fields.editors-title}}",
|
||||
"teams-title": "{{metadata-fields.teams-title}}",
|
||||
"locations-title": "{{metadata-fields.locations-title}}",
|
||||
"imprints-title": "{{metadata-fields.imprints-title}}",
|
||||
"genres-title": "{{metadata-fields.genres-title}}",
|
||||
"tags-title": "{{metadata-fields.tags-title}}",
|
||||
"weblinks-title": "{{tabs.weblink-tab}}"
|
||||
},
|
||||
|
||||
"related-tab": {
|
||||
@ -1075,18 +1079,18 @@
|
||||
|
||||
"chapter-metadata-detail": {
|
||||
"no-data": "No metadata available",
|
||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
||||
"publishers-title": "{{series-metadata-detail.publishers-title}}",
|
||||
"characters-title": "{{series-metadata-detail.characters-title}}",
|
||||
"translators-title": "{{series-metadata-detail.translators-title}}",
|
||||
"letterers-title": "{{series-metadata-detail.letterers-title}}",
|
||||
"colorists-title": "{{series-metadata-detail.colorists-title}}",
|
||||
"inkers-title": "{{series-metadata-detail.inkers-title}}",
|
||||
"pencillers-title": "{{series-metadata-detail.pencillers-title}}",
|
||||
"cover-artists-title": "{{series-metadata-detail.cover-artists-title}}",
|
||||
"editors-title": "{{series-metadata-detail.editors-title}}",
|
||||
"teams-title": "{{series-metadata-detail.teams-title}}",
|
||||
"locations-title": "{{series-metadata-detail.locations-title}}"
|
||||
"writers-title": "{{metadata-fields.writers-title}}",
|
||||
"publishers-title": "{{metadata-fields.publishers-title}}",
|
||||
"characters-title": "{{metadata-fields.characters-title}}",
|
||||
"translators-title": "{{metadata-fields.translators-title}}",
|
||||
"letterers-title": "{{metadata-fields.letterers-title}}",
|
||||
"colorists-title": "{{metadata-fields.colorists-title}}",
|
||||
"inkers-title": "{{metadata-fields.inkers-title}}",
|
||||
"pencillers-title": "{metadata-fields.pencillers-title}}",
|
||||
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
|
||||
"editors-title": "{{metadata-fields.editors-title}}",
|
||||
"teams-title": "{{metadata-fields.teams-title}}",
|
||||
"locations-title": "{{metadata-fields.locations-title}}"
|
||||
},
|
||||
|
||||
"cover-image-chooser": {
|
||||
@ -1120,11 +1124,11 @@
|
||||
},
|
||||
|
||||
"entity-info-cards": {
|
||||
"tags-title": "{{series-metadata-detail.tags-title}}",
|
||||
"characters-title": "{{series-metadata-detail.characters-title}}",
|
||||
"tags-title": "{{metadata-fields.tags-title}}",
|
||||
"characters-title": "{{metadata-fields.characters-title}}",
|
||||
"release-date-title": "Release",
|
||||
"release-date-tooltip": "Release Date",
|
||||
"age-rating-title": "Age Rating",
|
||||
"age-rating-title": "{{metadata-fields.age-rating-title}}",
|
||||
"length-title": "Length",
|
||||
"pages-count": "{{num}} Pages",
|
||||
"words-count": "{{num}} Words",
|
||||
@ -1133,7 +1137,7 @@
|
||||
"date-added-title": "Date Added",
|
||||
"size-title": "Size",
|
||||
"id-title": "ID",
|
||||
"links-title": "{{series-metadata-detail.links-title}}",
|
||||
"links-title": "{{metadata-fields.links-title}}",
|
||||
"isbn-title": "ISBN",
|
||||
"sort-order-title": "Sort Order",
|
||||
"last-read-title": "Last Read",
|
||||
@ -1148,7 +1152,7 @@
|
||||
"release-date-title": "{{entity-info-cards.release-date-title}}",
|
||||
"release-year-tooltip": "Release Year",
|
||||
"age-rating-title": "{{entity-info-cards.age-rating-title}}",
|
||||
"language-title": "Language",
|
||||
"language-title": "{{metadata-fields.language-title}}",
|
||||
"publication-status-title": "Publication",
|
||||
"publication-status-tooltip": "Publication Status",
|
||||
"scrobbling-title": "Scrobbling",
|
||||
@ -1514,7 +1518,7 @@
|
||||
"settings": {
|
||||
"account-section-title": "Account",
|
||||
"server-section-title": "Server",
|
||||
"manage-section-title": "Manage",
|
||||
"info-section-title": "Info",
|
||||
"import-section-title": "Import",
|
||||
"kavitaplus-section-title": "{{settings.admin-kavitaplus}}",
|
||||
"admin-general": "General",
|
||||
@ -1601,7 +1605,7 @@
|
||||
"read-options-alt": "Read options",
|
||||
"incognito-alt": "(Incognito)",
|
||||
"no-data": "Nothing added",
|
||||
"characters-title": "{{series-metadata-detail.characters-title}}"
|
||||
"characters-title": "{{metadata-fields.characters-title}}"
|
||||
},
|
||||
|
||||
"events-widget": {
|
||||
@ -1854,8 +1858,8 @@
|
||||
"read": "Read",
|
||||
"in-progress": "In Progress",
|
||||
"rating-label": "Rating",
|
||||
"age-rating-label": "Age Rating",
|
||||
"language-label": "Language",
|
||||
"age-rating-label": "{{metadata-fields.age-rating-title}}",
|
||||
"language-label": "{{metadata-fields.language-title}}",
|
||||
"publication-status-label": "Publication Status",
|
||||
"series-name-label": "Series Name",
|
||||
"series-name-tooltip": "Series name will filter against Name, Sort Name, or Localized Name",
|
||||
@ -1895,21 +1899,21 @@
|
||||
|
||||
"genres-label": "{{metadata-fields.genres-title}}",
|
||||
"tags-label": "{{metadata-fields.tags-title}}",
|
||||
"cover-artist-label": "Cover Artist",
|
||||
"writer-label": "Writer",
|
||||
"publisher-label": "Publisher",
|
||||
"imprint-label": "Imprint",
|
||||
"penciller-label": "Penciller",
|
||||
"letterer-label": "Letterer",
|
||||
"inker-label": "Inker",
|
||||
"editor-label": "Editor",
|
||||
"colorist-label": "Colorist",
|
||||
"character-label": "Character",
|
||||
"translator-label": "Translator",
|
||||
"team-label": "{{filter-field-pipe.team}}",
|
||||
"location-label": "{{filter-field-pipe.location}}",
|
||||
"language-label": "Language",
|
||||
"age-rating-label": "Age Rating",
|
||||
"cover-artist-label": "{{metadata-fields.cover-artists-title}}",
|
||||
"writer-label": "{{metadata-fields.writers-title}}",
|
||||
"publisher-label": "{{metadata-fields.publishers-title}}",
|
||||
"imprint-label": "{{metadata-fields.imprints-title}}",
|
||||
"penciller-label": "{{metadata-fields.pencillers-title}}",
|
||||
"letterer-label": "{{metadata-fields.letterers-title}}",
|
||||
"inker-label": "{{metadata-fields.inkers-title}}",
|
||||
"editor-label": "{{metadata-fields.editors-title}}",
|
||||
"colorist-label": "{{metadata-fields.colorists-title}}",
|
||||
"character-label": "{{metadata-fields.characters-title}}",
|
||||
"translator-label": "{{metadata-fields.translators-title}}",
|
||||
"team-label": "{{metadata-fields.teams-title}}",
|
||||
"location-label": "{{metadata-fields.locations-title}}",
|
||||
"language-label": "{{metadata-fields.language-title}}",
|
||||
"age-rating-label": "{{metadata-fields.age-rating-title}}",
|
||||
|
||||
"publication-status-label": "Publication Status",
|
||||
"required-field": "{{validation.required-field}}",
|
||||
@ -1920,7 +1924,7 @@
|
||||
"summary-label": "Summary",
|
||||
"release-year-label": "Release Year",
|
||||
"web-link-description": "Here you can add many different links to external services.",
|
||||
"web-link-label": "Web Link",
|
||||
"web-link-label": "{{tabs.weblink-tab}}",
|
||||
"cover-image-description": "Upload and choose a new cover image. Press Save to upload and override the cover.",
|
||||
"save": "{{common.save}}",
|
||||
"field-locked-alt": "Field is locked",
|
||||
@ -1937,6 +1941,7 @@
|
||||
"lowest-folder-path-tooltip": "Lowest path from library root that contains all series files",
|
||||
"publication-status-title": "Publication Status",
|
||||
"total-pages-title": "Total Pages",
|
||||
"total-words-title": "Total Words",
|
||||
"total-items-title": "Total Items",
|
||||
"max-items-title": "Max Items",
|
||||
"size-title": "Size",
|
||||
@ -1953,7 +1958,8 @@
|
||||
"force-refresh": "Force Refresh",
|
||||
"force-refresh-tooltip": "Force refresh external metadata from Kavita+",
|
||||
"loose-leaf-volume": "Loose Leaf Chapters",
|
||||
"specials-volume": "Specials"
|
||||
"specials-volume": "Specials",
|
||||
"release-year-validation": "{{validation.year-validation}}"
|
||||
},
|
||||
|
||||
"edit-chapter-modal": {
|
||||
@ -2253,7 +2259,7 @@
|
||||
},
|
||||
|
||||
"filter-field-pipe": {
|
||||
"age-rating": "Age Rating",
|
||||
"age-rating": "{{metadata-fields.age-rating-title}}",
|
||||
"characters": "{{metadata-fields.characters-title}}",
|
||||
"collection-tags": "Collection Tags",
|
||||
"colorist": "Colorist",
|
||||
@ -2477,7 +2483,6 @@
|
||||
"import-mal-stack": "Import MAL Stack",
|
||||
"import-mal-stack-tooltip": "Creates a Smart Collection from your MAL Interest Stacks",
|
||||
"read": "Read",
|
||||
"read-tooltip": "",
|
||||
"customize": "Customize",
|
||||
"customize-tooltip": "TODO",
|
||||
"mark-visible": "Mark as Visible",
|
||||
@ -2531,7 +2536,8 @@
|
||||
"validation": {
|
||||
"required-field": "This field is required",
|
||||
"valid-email": "This must be a valid email",
|
||||
"password-validation": "Password must be between 6 and 32 characters in length"
|
||||
"password-validation": "Password must be between 6 and 32 characters in length",
|
||||
"year-validation": "This must be a valid year greater than 1000 and 4 characters long"
|
||||
},
|
||||
|
||||
"entity-type": {
|
||||
|
@ -87,7 +87,6 @@ label, select, .clickable {
|
||||
app-root {
|
||||
background-color: transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--primary-color-scrollbar);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@ -96,7 +95,6 @@ app-root {
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-clip: padding-box;
|
||||
background-color: var(--primary-color-scrollbar);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 8px;
|
||||
min-height: 50px;
|
||||
@ -109,6 +107,6 @@ body {
|
||||
|
||||
.setting-section-break {
|
||||
height: 1px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
background-color: var(--setting-break-color);
|
||||
margin: 30px 0;
|
||||
}
|