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.db
|
||||||
/API/config/Hangfire-log.db
|
/API/config/Hangfire-log.db
|
||||||
API/config/covers/
|
API/config/covers/
|
||||||
|
API/config/images/*
|
||||||
API/config/stats/*
|
API/config/stats/*
|
||||||
API/config/stats/app_stats.json
|
API/config/stats/app_stats.json
|
||||||
API/config/pre-metadata/
|
API/config/pre-metadata/
|
||||||
@ -539,3 +540,6 @@ BenchmarkDotNet.Artifacts
|
|||||||
API.Tests/Services/Test Data/ImageService/**/*_output*
|
API.Tests/Services/Test Data/ImageService/**/*_output*
|
||||||
API.Tests/Services/Test Data/ImageService/**/*_baseline*
|
API.Tests/Services/Test Data/ImageService/**/*_baseline*
|
||||||
API.Tests/Services/Test Data/ImageService/**/*.html
|
API.Tests/Services/Test Data/ImageService/**/*.html
|
||||||
|
|
||||||
|
|
||||||
|
API.Tests/Services/Test Data/ScannerService/ScanTests/**/*
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
|
||||||
|
@ -25,13 +25,21 @@ using Xunit;
|
|||||||
|
|
||||||
namespace API.Tests.Services;
|
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)
|
public ComicInfo GetComicInfo(string filePath)
|
||||||
@ -56,12 +64,33 @@ internal class MockReadingItemService : IReadingItemService
|
|||||||
|
|
||||||
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
|
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)
|
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 ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
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>>();
|
// var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
|
||||||
//
|
//
|
||||||
@ -239,7 +268,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
var fileSystem = CreateTestFilesystem();
|
var fileSystem = CreateTestFilesystem();
|
||||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
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 directoriesSeen = new HashSet<string>();
|
||||||
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
@ -259,7 +288,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
var fileSystem = CreateTestFilesystem();
|
var fileSystem = CreateTestFilesystem();
|
||||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
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,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||||
@ -294,7 +323,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
|
|
||||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
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,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
||||||
@ -323,7 +352,7 @@ public class ParseScannedFilesTests : AbstractDbTest
|
|||||||
|
|
||||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
|
||||||
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
|
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,
|
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
|
||||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
|
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.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;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Metadata;
|
using API.Helpers;
|
||||||
using API.Extensions;
|
|
||||||
using API.Helpers.Builders;
|
using API.Helpers.Builders;
|
||||||
|
using API.Services;
|
||||||
|
using API.Services.Plus;
|
||||||
using API.Services.Tasks;
|
using API.Services.Tasks;
|
||||||
|
using API.Services.Tasks.Metadata;
|
||||||
using API.Services.Tasks.Scanner;
|
using API.Services.Tasks.Scanner;
|
||||||
using API.Services.Tasks.Scanner.Parser;
|
using API.Services.Tasks.Scanner.Parser;
|
||||||
|
using API.SignalR;
|
||||||
using API.Tests.Helpers;
|
using API.Tests.Helpers;
|
||||||
|
using Hangfire;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
namespace API.Tests.Services;
|
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]
|
[Fact]
|
||||||
public void FindSeriesNotOnDisk_Should_Remove1()
|
public void FindSeriesNotOnDisk_Should_Remove1()
|
||||||
{
|
{
|
||||||
@ -68,4 +103,182 @@ public class ScannerServiceTests
|
|||||||
|
|
||||||
Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos));
|
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>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageReference Include="MailKit" Version="4.7.1.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>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@ -70,20 +70,20 @@
|
|||||||
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
|
<PackageReference Include="Hangfire.InMemory" Version="0.10.3" />
|
||||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
|
<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="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||||
<PackageReference Include="NetVips" Version="2.4.1" />
|
<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="NReco.Logging.File" Version="1.2.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.0.1" />
|
<PackageReference Include="Serilog" Version="4.0.1" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||||
@ -96,15 +96,15 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
<PackageReference Include="SharpCompress" Version="0.37.2" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
|
<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>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.1" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
|
<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" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ public class CblController : BaseApiController
|
|||||||
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("validate")]
|
[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();
|
var userId = User.GetUserId();
|
||||||
try
|
try
|
||||||
@ -85,7 +85,7 @@ public class CblController : BaseApiController
|
|||||||
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
/// <param name="comicVineMatching">Use comic vine matching or not. Defaults to false</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("import")]
|
[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
|
try
|
||||||
{
|
{
|
||||||
|
@ -242,6 +242,46 @@ public class ImageController : BaseApiController
|
|||||||
return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName));
|
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>
|
/// <summary>
|
||||||
/// Returns a temp coverupload image
|
/// Returns a temp coverupload image
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -34,5 +34,4 @@ public enum LibraryType
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Description("Comic (Comic Vine)")]
|
[Description("Comic (Comic Vine)")]
|
||||||
ComicVine = 5,
|
ComicVine = 5,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ public interface IDirectoryService
|
|||||||
string LocalizationDirectory { get; }
|
string LocalizationDirectory { get; }
|
||||||
string CustomizedTemplateDirectory { get; }
|
string CustomizedTemplateDirectory { get; }
|
||||||
string TemplateDirectory { get; }
|
string TemplateDirectory { get; }
|
||||||
|
string PublisherDirectory { get; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
|
/// Original BookmarkDirectory. Only used for resetting directory. Use <see cref="ServerSettingKey.BackupDirectory"/> for actual path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -88,6 +89,7 @@ public class DirectoryService : IDirectoryService
|
|||||||
public string LocalizationDirectory { get; }
|
public string LocalizationDirectory { get; }
|
||||||
public string CustomizedTemplateDirectory { get; }
|
public string CustomizedTemplateDirectory { get; }
|
||||||
public string TemplateDirectory { get; }
|
public string TemplateDirectory { get; }
|
||||||
|
public string PublisherDirectory { get; }
|
||||||
private readonly ILogger<DirectoryService> _logger;
|
private readonly ILogger<DirectoryService> _logger;
|
||||||
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
private const RegexOptions MatchOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase;
|
||||||
|
|
||||||
@ -125,6 +127,8 @@ public class DirectoryService : IDirectoryService
|
|||||||
ExistOrCreate(CustomizedTemplateDirectory);
|
ExistOrCreate(CustomizedTemplateDirectory);
|
||||||
TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates");
|
TemplateDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "EmailTemplates");
|
||||||
ExistOrCreate(TemplateDirectory);
|
ExistOrCreate(TemplateDirectory);
|
||||||
|
PublisherDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "images", "publishers");
|
||||||
|
ExistOrCreate(PublisherDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -3,6 +3,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Services.Tasks.Scanner;
|
using API.Services.Tasks.Scanner;
|
||||||
|
using Hangfire;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
@ -45,7 +46,8 @@ public class StartupTasksHostedService : IHostedService
|
|||||||
if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching)
|
if ((await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableFolderWatching)
|
||||||
{
|
{
|
||||||
var libraryWatcher = scope.ServiceProvider.GetRequiredService<ILibraryWatcher>();
|
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)
|
catch (Exception)
|
||||||
|
@ -67,6 +67,7 @@ public interface IImageService
|
|||||||
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
|
||||||
Task<bool> IsImage(string filePath);
|
Task<bool> IsImage(string filePath);
|
||||||
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
|
||||||
|
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||||
void UpdateColorScape(IHasCoverImage entity);
|
void UpdateColorScape(IHasCoverImage entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,7 +381,7 @@ public class ImageService : IImageService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(correctSizeLink))
|
if (string.IsNullOrEmpty(correctSizeLink))
|
||||||
{
|
{
|
||||||
correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl);
|
correctSizeLink = await FallbackToKavitaReaderFavicon(baseUrl);
|
||||||
}
|
}
|
||||||
if (string.IsNullOrEmpty(correctSizeLink))
|
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;
|
return filename;
|
||||||
} catch (Exception ex)
|
} 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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -565,18 +612,12 @@ public class ImageService : IImageService
|
|||||||
return centroids;
|
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)
|
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();
|
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 =>
|
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 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))
|
if (!string.IsNullOrEmpty(allOverrides))
|
||||||
{
|
{
|
||||||
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
|
||||||
@ -699,17 +740,51 @@ public class ImageService : IImageService
|
|||||||
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
|
||||||
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
|
||||||
));
|
));
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(externalFile))
|
if (string.IsNullOrEmpty(externalFile))
|
||||||
{
|
{
|
||||||
throw new KavitaException($"Could not grab favicon from {baseUrl}");
|
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;
|
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 />
|
/// <inheritdoc />
|
||||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
|
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()}";
|
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)
|
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);
|
return Color.FromArgb(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.32.0.97167">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
$image-height: 230px;
|
$image-height: 232.91px;
|
||||||
$image-width: 160px;
|
$image-width: 160px;
|
||||||
|
|
||||||
.error-banner {
|
.error-banner {
|
||||||
@ -118,7 +118,7 @@ $image-width: 160px;
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 230px;
|
height: 232.91px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
border-top-left-radius: 4px;
|
border-top-left-radius: 4px;
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 4px;
|
||||||
|
@ -7,16 +7,16 @@ import {ScrobbleProvider} from "../_services/scrobbling.service";
|
|||||||
})
|
})
|
||||||
export class ProviderImagePipe implements PipeTransform {
|
export class ProviderImagePipe implements PipeTransform {
|
||||||
|
|
||||||
transform(value: ScrobbleProvider): string {
|
transform(value: ScrobbleProvider, large: boolean = false): string {
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case ScrobbleProvider.AniList:
|
case ScrobbleProvider.AniList:
|
||||||
return 'assets/images/ExternalServices/AniList.png';
|
return `assets/images/ExternalServices/AniList${large ? '-lg' : ''}.png`;
|
||||||
case ScrobbleProvider.Mal:
|
case ScrobbleProvider.Mal:
|
||||||
return 'assets/images/ExternalServices/MAL.png';
|
return `assets/images/ExternalServices/MAL${large ? '-lg' : ''}.png`;
|
||||||
case ScrobbleProvider.GoogleBooks:
|
case ScrobbleProvider.GoogleBooks:
|
||||||
return 'assets/images/ExternalServices/GoogleBooks.png';
|
return `assets/images/ExternalServices/GoogleBooks${large ? '-lg' : ''}.png`;
|
||||||
case ScrobbleProvider.Kavita:
|
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; }
|
if (this.collectionModalRef != null) { return; }
|
||||||
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection', fullscreen: 'md' });
|
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.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.closed.pipe(take(1)).subscribe(() => {
|
||||||
this.collectionModalRef = null;
|
this.collectionModalRef = null;
|
||||||
|
@ -217,14 +217,22 @@ export class ColorscapeService {
|
|||||||
private setColorsImmediately(colors: ColorSpaceRGBA) {
|
private setColorsImmediately(colors: ColorSpaceRGBA) {
|
||||||
this.injectStyleElement(colorScapeSelector, `
|
this.injectStyleElement(colorScapeSelector, `
|
||||||
:root, :root .default {
|
:root, :root .default {
|
||||||
--colorscape-primary-color: ${this.rgbaToString(colors.primary)};
|
--colorscape-primary-color: ${this.rgbToString(colors.primary)};
|
||||||
--colorscape-lighter-color: ${this.rgbaToString(colors.lighter)};
|
--colorscape-lighter-color: ${this.rgbToString(colors.lighter)};
|
||||||
--colorscape-darker-color: ${this.rgbaToString(colors.darker)};
|
--colorscape-darker-color: ${this.rgbToString(colors.darker)};
|
||||||
--colorscape-complementary-color: ${this.rgbaToString(colors.complementary)};
|
--colorscape-complementary-color: ${this.rgbToString(colors.complementary)};
|
||||||
--colorscape-primary-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })};
|
--colorscape-primary-no-alpha-color: ${this.rgbaToString({ ...colors.primary, a: 0 })};
|
||||||
--colorscape-lighter-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })};
|
--colorscape-lighter-no-alpha-color: ${this.rgbaToString({ ...colors.lighter, a: 0 })};
|
||||||
--colorscape-darker-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })};
|
--colorscape-darker-no-alpha-color: ${this.rgbaToString({ ...colors.darker, a: 0 })};
|
||||||
--colorscape-complementary-alpha-color: ${this.rgbaToString({ ...colors.complementary, 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})`;
|
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 {
|
private getCssVariable(variableName: string): string {
|
||||||
return getComputedStyle(this.document.body).getPropertyValue(variableName).trim();
|
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}`;
|
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) {
|
getCoverUploadImage(filename: string) {
|
||||||
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
|
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);
|
return this.httpClient.get<boolean>(this.baseUrl + 'readinglist/name-exists?name=' + name);
|
||||||
}
|
}
|
||||||
|
|
||||||
validateCbl(form: FormData) {
|
validateCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) {
|
||||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/validate', form);
|
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/validate?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
importCbl(form: FormData) {
|
importCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) {
|
||||||
return this.httpClient.post<CblImportSummary>(this.baseUrl + 'cbl/import', form);
|
return this.httpClient.post<CblImportSummary>(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCharacters(readingListId: number) {
|
getCharacters(readingListId: number) {
|
||||||
|
@ -20,6 +20,17 @@
|
|||||||
</app-carousel-reel>
|
</app-carousel-reel>
|
||||||
</div>
|
</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) {
|
@if (genres.length > 0 || tags.length > 0) {
|
||||||
<div class="setting-section-break" aria-hidden="true"></div>
|
<div class="setting-section-break" aria-hidden="true"></div>
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,9 @@ import {FilterUtilitiesService} from "../../shared/_services/filter-utilities.se
|
|||||||
import {Genre} from "../../_models/metadata/genre";
|
import {Genre} from "../../_models/metadata/genre";
|
||||||
import {Tag} from "../../_models/tag";
|
import {Tag} from "../../_models/tag";
|
||||||
import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badge.component";
|
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({
|
@Component({
|
||||||
selector: 'app-details-tab',
|
selector: 'app-details-tab',
|
||||||
@ -19,7 +22,9 @@ import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badg
|
|||||||
CarouselReelComponent,
|
CarouselReelComponent,
|
||||||
PersonBadgeComponent,
|
PersonBadgeComponent,
|
||||||
TranslocoDirective,
|
TranslocoDirective,
|
||||||
TagBadgeComponent
|
TagBadgeComponent,
|
||||||
|
ImageComponent,
|
||||||
|
SafeHtmlPipe
|
||||||
],
|
],
|
||||||
templateUrl: './details-tab.component.html',
|
templateUrl: './details-tab.component.html',
|
||||||
styleUrl: './details-tab.component.scss',
|
styleUrl: './details-tab.component.scss',
|
||||||
@ -27,14 +32,16 @@ import {TagBadgeComponent, TagBadgeCursor} from "../../shared/tag-badge/tag-badg
|
|||||||
})
|
})
|
||||||
export class DetailsTabComponent {
|
export class DetailsTabComponent {
|
||||||
|
|
||||||
private readonly router = inject(Router);
|
protected readonly imageService = inject(ImageService);
|
||||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||||
|
|
||||||
protected readonly PersonRole = PersonRole;
|
protected readonly PersonRole = PersonRole;
|
||||||
protected readonly FilterField = FilterField;
|
protected readonly FilterField = FilterField;
|
||||||
|
|
||||||
@Input({required: true}) metadata!: IHasCast;
|
@Input({required: true}) metadata!: IHasCast;
|
||||||
@Input() genres: Array<Genre> = [];
|
@Input() genres: Array<Genre> = [];
|
||||||
@Input() tags: Array<Tag> = [];
|
@Input() tags: Array<Tag> = [];
|
||||||
|
@Input() webLinks: Array<string> = [];
|
||||||
|
|
||||||
|
|
||||||
openPerson(queryParamName: FilterField, filter: Person) {
|
openPerson(queryParamName: FilterField, filter: Person) {
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<ng-container *ngIf="CoverUrl as coverUrl">
|
<ng-container *ngIf="CoverUrl as coverUrl">
|
||||||
<div style="width: 160px" class="mx-auto mb-3">
|
<div style="width: 160px" class="mx-auto mb-3">
|
||||||
<app-image *ngIf="coverUrl" height="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>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
@ -46,16 +46,16 @@
|
|||||||
.default-background {
|
.default-background {
|
||||||
background: radial-gradient(circle farthest-side at 0% 100%,
|
background: radial-gradient(circle farthest-side at 0% 100%,
|
||||||
var(--colorscape-darker-color) 0%,
|
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%,
|
radial-gradient(circle farthest-side at 100% 100%,
|
||||||
var(--colorscape-primary-color) 0%,
|
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%,
|
radial-gradient(circle farthest-side at 100% 0%,
|
||||||
var(--colorscape-lighter-color) 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%,
|
radial-gradient(circle farthest-side at 0% 0%,
|
||||||
var(--colorscape-complementary-color) 0%,
|
var(--colorscape-complementary-color) 0%,
|
||||||
var(--colorscape-complementary-alpha-color) 100%),
|
var(--colorscape-complementary-no-alpha-color) 100%),
|
||||||
var(--bs-body-bg);
|
var(--bs-body-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,6 +68,9 @@
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background-color: #121212;
|
background-color: #121212;
|
||||||
|
filter: blur(20px);
|
||||||
|
object-fit: contain;
|
||||||
|
transform: scale(1.1);
|
||||||
|
|
||||||
.background-area {
|
.background-area {
|
||||||
position: absolute;
|
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.
|
// Sets a CSS variable for the actual device viewport height. Needed for mobile dev.
|
||||||
const vh = window.innerHeight * 0.01;
|
const vh = window.innerHeight * 0.01;
|
||||||
this.document.documentElement.style.setProperty('--vh', `${vh}px`);
|
this.document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
|
this.utilityService.activeBreakpointSource.next(this.utilityService.getActiveBreakpoint());
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<ng-container *transloco="let t; read: 'edit-series-modal'">
|
<ng-container *transloco="let t; read: 'edit-series-modal'">
|
||||||
<div class="modal-container" *ngIf="series !== undefined">
|
@if (series) {
|
||||||
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">
|
<h5 class="modal-title">
|
||||||
{{t('title', {seriesName: this.series.name})}}</h5>
|
{{t('title', {seriesName: this.series.name})}}</h5>
|
||||||
@ -85,7 +86,8 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
|
@if (metadata) {
|
||||||
|
<li [ngbNavItem]="tabs[TabID.Metadata]">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.Metadata])}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.Metadata])}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
|
|
||||||
@ -117,11 +119,11 @@
|
|||||||
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear"
|
<input type="number" inputmode="numeric" class="form-control" id="release-year" formControlName="releaseYear"
|
||||||
maxlength="4" minlength="4"
|
maxlength="4" minlength="4"
|
||||||
[class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
|
[class.is-invalid]="editSeriesForm.get('releaseYear')?.invalid && editSeriesForm.get('releaseYear')?.touched">
|
||||||
<ng-container *ngIf="editSeriesForm.get('releaseYear')?.errors as errors">
|
@if (editSeriesForm.get('releaseYear')?.errors; as errors) {
|
||||||
<p class="invalid-feedback" *ngIf="errors.pattern">
|
@if (errors.pattern) {
|
||||||
This must be a valid year greater than 1000 and 4 characters long
|
<p class="invalid-feedback">{{t('release-year-validation')}}</p>
|
||||||
</p>
|
}
|
||||||
</ng-container>
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
@ -195,7 +197,9 @@
|
|||||||
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
|
<div class="input-group {{metadata.publicationStatusLocked ? 'lock-active' : ''}}">
|
||||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'publicationStatusLocked' }"></ng-container>
|
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'publicationStatusLocked' }"></ng-container>
|
||||||
<select class="form-select" id="publication-status" formControlName="publicationStatus">
|
<select class="form-select" id="publication-status" formControlName="publicationStatus">
|
||||||
<option *ngFor="let opt of publicationStatuses" [value]="opt.value">{{opt.title | titlecase}}</option>
|
@for (opt of publicationStatuses; track opt.value) {
|
||||||
|
<option [value]="opt.value">{{opt.title | titlecase}}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@ -205,6 +209,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.People]">
|
<li [ngbNavItem]="tabs[TabID.People]">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.People])}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.People])}}</a>
|
||||||
@ -460,13 +466,15 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.WebLinks]" *ngIf="metadata">
|
@if (metadata) {
|
||||||
|
<li [ngbNavItem]="tabs[TabID.WebLinks]">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.WebLinks])}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.WebLinks])}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<p>{{t('web-link-description')}}</p>
|
<p>{{t('web-link-description')}}</p>
|
||||||
<app-edit-list [items]="WebLinks" [label]="t('web-link-label')" (updateItems)="updateWeblinks($event)"></app-edit-list>
|
<app-edit-list [items]="WebLinks" [label]="t('web-link-label')" (updateItems)="updateWeblinks($event)"></app-edit-list>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
||||||
<a ngbNavLink>{{t(tabs[TabID.CoverImage])}}</a>
|
<a ngbNavLink>{{t(tabs[TabID.CoverImage])}}</a>
|
||||||
@ -504,7 +512,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<app-setting-item [title]="t('format-title')" [toggleOnViewClick]="false" [showEdit]="false">
|
<app-setting-item [title]="t('format-title')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||||
<ng-template #view>
|
<ng-template #view>
|
||||||
<app-tag-badge>{{series.format | mangaFormat}}</app-tag-badge>
|
{{series.format | mangaFormat}}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-item>
|
</app-setting-item>
|
||||||
</div>
|
</div>
|
||||||
@ -641,7 +649,7 @@
|
|||||||
|
|
||||||
<h4>Volumes</h4>
|
<h4>Volumes</h4>
|
||||||
@if (isLoadingVolumes) {
|
@if (isLoadingVolumes) {
|
||||||
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
<div class="spinner-border text-secondary" role="status">
|
||||||
<span class="visually-hidden">{{t('loading')}}</span>
|
<span class="visually-hidden">{{t('loading')}}</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@ -743,6 +751,8 @@
|
|||||||
<span class="visually-hidden">{{t('field-locked-alt')}}</span>
|
<span class="visually-hidden">{{t('field-locked-alt')}}</span>
|
||||||
</span>
|
</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -146,7 +146,7 @@ export class CardDetailDrawerComponent implements OnInit {
|
|||||||
|
|
||||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
|
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
|
||||||
.filter(item => item.action !== Action.Edit);
|
.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) {
|
if (this.isChapter) {
|
||||||
const chapter = this.utilityService.asChapter(this.data);
|
const chapter = this.utilityService.asChapter(this.data);
|
||||||
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
|
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
$image-height: 230px;
|
$image-height: 232.91px;
|
||||||
$image-width: 160px;
|
$image-width: 160px;
|
||||||
|
|
||||||
.card-img-top {
|
.card-img-top {
|
||||||
|
@ -37,12 +37,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@case (LibraryType.Book) {
|
@case (LibraryType.Book) {
|
||||||
|
@if (titleName !== '' && prioritizeTitleName) {
|
||||||
|
{{titleName}}
|
||||||
|
} @else {
|
||||||
{{volumeTitle}}
|
{{volumeTitle}}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@case (LibraryType.LightNovel) {
|
@case (LibraryType.LightNovel) {
|
||||||
|
@if (titleName !== '' && prioritizeTitleName) {
|
||||||
|
{{titleName}}
|
||||||
|
} @else {
|
||||||
{{volumeTitle}}
|
{{volumeTitle}}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@case (LibraryType.Images) {
|
@case (LibraryType.Images) {
|
||||||
{{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}
|
{{number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + number : volumeTitle) : t('special')}}
|
||||||
|
@ -227,7 +227,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||||||
this.scanLibrary(series);
|
this.scanLibrary(series);
|
||||||
break;
|
break;
|
||||||
case(Action.RefreshMetadata):
|
case(Action.RefreshMetadata):
|
||||||
this.refreshMetadata(series);
|
this.refreshMetadata(series, true);
|
||||||
break;
|
break;
|
||||||
case(Action.GenerateColorScape):
|
case(Action.GenerateColorScape):
|
||||||
this.refreshMetadata(series, false);
|
this.refreshMetadata(series, false);
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
<div class="card-title-container">
|
<div class="card-title-container">
|
||||||
<span class="card-title" id="{{volume.id}}" tabindex="0">
|
<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}}
|
{{volume.name}}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</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">
|
<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>
|
<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'}}%">
|
<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>
|
<ngb-progressbar type="primary" [value]="chapter.pagesRead" [max]="chapter.pages" [showValue]="true"></ngb-progressbar>
|
||||||
</div>
|
</div>
|
||||||
@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
<app-metadata-detail-row [entity]="chapter"
|
<app-metadata-detail-row [entity]="chapter"
|
||||||
[ageRating]="chapter.ageRating"
|
[ageRating]="chapter.ageRating"
|
||||||
[hasReadingProgress]="hasReadingProgress"
|
[hasReadingProgress]="chapter.pagesRead > 0"
|
||||||
[readingTimeEntity]="chapter"
|
[readingTimeEntity]="chapter"
|
||||||
[libraryType]="libraryType">
|
[libraryType]="libraryType">
|
||||||
</app-metadata-detail-row>
|
</app-metadata-detail-row>
|
||||||
@ -64,8 +64,8 @@
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-primary-outline" (click)="read()">
|
<button type="button" class="btn btn-primary-outline" (click)="read()">
|
||||||
<span>
|
<span>
|
||||||
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
<i class="fa {{chapter.pagesRead > 0 ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
<span class="read-btn--text"> {{(chapter.pagesRead > 0) ? t('continue') : t('read')}}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||||
@ -74,7 +74,7 @@
|
|||||||
<button ngbDropdownItem (click)="read(true)">
|
<button ngbDropdownItem (click)="read(true)">
|
||||||
<span>
|
<span>
|
||||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
|
<span class="read-btn--text"> {{(chapter.pagesRead > 0) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
<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>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@ -111,9 +111,6 @@
|
|||||||
<app-badge-expander [items]="chapter.writers">
|
<app-badge-expander [items]="chapter.writers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
|
||||||
@if (!last) {
|
|
||||||
,
|
|
||||||
}
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
@ -124,9 +121,6 @@
|
|||||||
<app-badge-expander [items]="chapter.coverArtists">
|
<app-badge-expander [items]="chapter.coverArtists">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
||||||
@if (!last) {
|
|
||||||
,
|
|
||||||
}
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
|
@ -75,6 +75,7 @@ import {DownloadButtonComponent} from "../series-detail/_components/download-but
|
|||||||
import {hasAnyCast} from "../_models/common/i-has-cast";
|
import {hasAnyCast} from "../_models/common/i-has-cast";
|
||||||
import {CarouselTabComponent} from "../carousel/_components/carousel-tab/carousel-tab.component";
|
import {CarouselTabComponent} from "../carousel/_components/carousel-tab/carousel-tab.component";
|
||||||
import {CarouselTabsComponent, TabId} from "../carousel/_components/carousel-tabs/carousel-tabs.component";
|
import {CarouselTabsComponent, TabId} from "../carousel/_components/carousel-tabs/carousel-tabs.component";
|
||||||
|
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
Related = 'related-tab',
|
Related = 'related-tab',
|
||||||
@ -156,6 +157,7 @@ export class ChapterDetailComponent implements OnInit {
|
|||||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly readingListService = inject(ReadingListService);
|
private readonly readingListService = inject(ReadingListService);
|
||||||
|
protected readonly utilityService = inject(UtilityService);
|
||||||
|
|
||||||
|
|
||||||
protected readonly AgeRating = AgeRating;
|
protected readonly AgeRating = AgeRating;
|
||||||
@ -316,4 +318,5 @@ export class ChapterDetailComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected readonly TabId = TabId;
|
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 {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe";
|
||||||
import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload";
|
import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload";
|
||||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
|
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms";
|
||||||
import {NgForOf, NgIf, NgTemplateOutlet} from "@angular/common";
|
import {NgTemplateOutlet} from "@angular/common";
|
||||||
import {
|
import {
|
||||||
NgbAccordionBody,
|
NgbAccordionBody,
|
||||||
NgbAccordionButton,
|
NgbAccordionButton,
|
||||||
@ -11,7 +11,6 @@ import {
|
|||||||
NgbAccordionDirective,
|
NgbAccordionDirective,
|
||||||
NgbAccordionHeader,
|
NgbAccordionHeader,
|
||||||
NgbAccordionItem,
|
NgbAccordionItem,
|
||||||
NgbActiveModal
|
|
||||||
} from "@ng-bootstrap/ng-bootstrap";
|
} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||||
import {StepTrackerComponent, TimelineStep} from "../step-tracker/step-tracker.component";
|
import {StepTrackerComponent, TimelineStep} from "../step-tracker/step-tracker.component";
|
||||||
@ -133,7 +132,7 @@ export class ImportCblComponent {
|
|||||||
formData.append('cbl', files[i]);
|
formData.append('cbl', files[i]);
|
||||||
formData.append('dryRun', 'true');
|
formData.append('dryRun', 'true');
|
||||||
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
|
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 => {
|
forkJoin(pages).subscribe(results => {
|
||||||
@ -225,7 +224,7 @@ export class ImportCblComponent {
|
|||||||
formData.append('cbl', files[i]);
|
formData.append('cbl', files[i]);
|
||||||
formData.append('dryRun', 'true');
|
formData.append('dryRun', 'true');
|
||||||
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
|
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 => {
|
forkJoin(pages).subscribe(results => {
|
||||||
results.forEach(cblImport => {
|
results.forEach(cblImport => {
|
||||||
@ -250,7 +249,7 @@ export class ImportCblComponent {
|
|||||||
formData.append('cbl', files[i]);
|
formData.append('cbl', files[i]);
|
||||||
formData.append('dryRun', 'false');
|
formData.append('dryRun', 'false');
|
||||||
formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + '');
|
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 => {
|
forkJoin(pages).subscribe(results => {
|
||||||
|
@ -117,7 +117,8 @@
|
|||||||
<h5>{{t('characters-title')}}</h5>
|
<h5>{{t('characters-title')}}</h5>
|
||||||
<app-badge-expander [items]="characters">
|
<app-badge-expander [items]="characters">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx">
|
<ng-template #badgeExpanderItem let-item let-position="idx">
|
||||||
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item" (click)="goToCharacter(item)"></app-person-badge>
|
<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>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<ng-container *transloco="let t; read: 'external-rating'">
|
<ng-container *transloco="let t; read: 'external-rating'">
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
|
<div class="col-auto custom-col clickable" [ngbPopover]="popContent"
|
||||||
popoverTitle="Your Rating + Overall" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
|
[popoverTitle]="t('kavita-tooltip')" [popoverClass]="utilityService.getActiveBreakpoint() > Breakpoint.Mobile ? 'md-popover' : 'lg-popover'">
|
||||||
<span class="badge rounded-pill ps-0 me-1">
|
<span class="badge rounded-pill ps-0 me-1">
|
||||||
<app-image classes="me-1" imageUrl="assets/images/logo-32.png" width="24px" height="24px" />
|
<app-image classes="me-1" imageUrl="assets/images/logo-32.png" width="24px" height="24px" />
|
||||||
@if (hasUserRated) {
|
@if (hasUserRated) {
|
||||||
@ -23,7 +23,7 @@
|
|||||||
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
|
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
|
||||||
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
|
[popoverTitle]="rating.provider | providerName" popoverClass="sm-popover">
|
||||||
<span class="badge rounded-pill me-1">
|
<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}}%
|
{{rating.averageScore}}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -32,6 +32,15 @@
|
|||||||
<div class="col-auto" style="padding-top: 8px">
|
<div class="col-auto" style="padding-top: 8px">
|
||||||
<app-loading [loading]="isLoading" size="spinner-border-sm"></app-loading>
|
<app-loading [loading]="isLoading" size="spinner-border-sm"></app-loading>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ng-template #popContent>
|
<ng-template #popContent>
|
||||||
|
@ -20,11 +20,13 @@ import {ThemeService} from "../../../_services/theme.service";
|
|||||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||||
import {ImageComponent} from "../../../shared/image/image.component";
|
import {ImageComponent} from "../../../shared/image/image.component";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
|
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||||
|
import {ImageService} from "../../../_services/image.service";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-external-rating',
|
selector: 'app-external-rating',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective],
|
imports: [CommonModule, ProviderImagePipe, NgOptimizedImage, NgbRating, NgbPopover, LoadingComponent, ProviderNamePipe, NgxStarsModule, ImageComponent, TranslocoDirective, SafeHtmlPipe],
|
||||||
templateUrl: './external-rating.component.html',
|
templateUrl: './external-rating.component.html',
|
||||||
styleUrls: ['./external-rating.component.scss'],
|
styleUrls: ['./external-rating.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -37,6 +39,8 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
private readonly themeService = inject(ThemeService);
|
private readonly themeService = inject(ThemeService);
|
||||||
public readonly utilityService = inject(UtilityService);
|
public readonly utilityService = inject(UtilityService);
|
||||||
public readonly destroyRef = inject(DestroyRef);
|
public readonly destroyRef = inject(DestroyRef);
|
||||||
|
public readonly imageService = inject(ImageService);
|
||||||
|
|
||||||
protected readonly Breakpoint = Breakpoint;
|
protected readonly Breakpoint = Breakpoint;
|
||||||
|
|
||||||
@Input({required: true}) seriesId!: number;
|
@Input({required: true}) seriesId!: number;
|
||||||
@ -44,6 +48,7 @@ export class ExternalRatingComponent implements OnInit {
|
|||||||
@Input({required: true}) hasUserRated!: boolean;
|
@Input({required: true}) hasUserRated!: boolean;
|
||||||
@Input({required: true}) libraryType!: LibraryType;
|
@Input({required: true}) libraryType!: LibraryType;
|
||||||
@Input({required: true}) ratings: Array<Rating> = [];
|
@Input({required: true}) ratings: Array<Rating> = [];
|
||||||
|
@Input() webLinks: Array<string> = [];
|
||||||
|
|
||||||
isLoading: boolean = false;
|
isLoading: boolean = false;
|
||||||
overallRating: number = -1;
|
overallRating: number = -1;
|
||||||
|
@ -1,28 +1,32 @@
|
|||||||
<ng-container *transloco="let t; read: 'series-detail'">
|
<ng-container *transloco="let t; read: 'series-detail'">
|
||||||
<div class="mt-2 mb-2">
|
<div class="mt-2 mb-2">
|
||||||
@if (entity.publishers.length > 0) {
|
@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">
|
<span class="me-2">
|
||||||
<app-age-rating-image [rating]="ageRating"></app-age-rating-image>
|
<app-age-rating-image [rating]="ageRating"></app-age-rating-image>
|
||||||
</span>
|
</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) {
|
@if (hasReadingProgress && readingTimeLeft && readingTimeLeft.avgHours !== 0) {
|
||||||
<span [ngbTooltip]="t('time-left-alt')">
|
<span class="time-left" [ngbTooltip]="t('time-left-alt')">
|
||||||
<i class="fa-solid fa-clock me-1" aria-hidden="true"></i>
|
<i class="fa-solid fa-clock" aria-hidden="true"></i>
|
||||||
{{readingTimeLeft | readTimeLeft }}
|
{{readingTimeLeft | readTimeLeft }}
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span [ngbTooltip]="t('time-to-read-alt')">
|
<span class="time-left" [ngbTooltip]="t('time-to-read-alt')">
|
||||||
<i class="fa-regular fa-clock me-1" aria-hidden="true"></i>
|
<i class="fa-regular fa-clock" aria-hidden="true"></i>
|
||||||
{{readingTimeEntity | readTime }}
|
{{readingTimeEntity | readTime }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
</ng-container>
|
</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 {AgeRatingImageComponent} from "../../../_single-modules/age-rating-image/age-rating-image.component";
|
||||||
import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe";
|
import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe";
|
||||||
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.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 {IHasReadingTime} from "../../../_models/common/i-has-reading-time";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
import {LibraryType} from "../../../_models/library/library";
|
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({
|
@Component({
|
||||||
selector: 'app-metadata-detail-row',
|
selector: 'app-metadata-detail-row',
|
||||||
@ -20,13 +25,18 @@ import {LibraryType} from "../../../_models/library/library";
|
|||||||
ReadTimeLeftPipe,
|
ReadTimeLeftPipe,
|
||||||
ReadTimePipe,
|
ReadTimePipe,
|
||||||
NgbTooltip,
|
NgbTooltip,
|
||||||
TranslocoDirective
|
TranslocoDirective,
|
||||||
|
ImageComponent
|
||||||
],
|
],
|
||||||
templateUrl: './metadata-detail-row.component.html',
|
templateUrl: './metadata-detail-row.component.html',
|
||||||
styleUrl: './metadata-detail-row.component.scss',
|
styleUrl: './metadata-detail-row.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class MetadataDetailRowComponent {
|
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}) entity!: IHasCast;
|
||||||
@Input({required: true}) readingTimeEntity!: IHasReadingTime;
|
@Input({required: true}) readingTimeEntity!: IHasReadingTime;
|
||||||
@ -35,5 +45,11 @@ export class MetadataDetailRowComponent {
|
|||||||
@Input({required: true}) ageRating: AgeRating = AgeRating.Unknown;
|
@Input({required: true}) ageRating: AgeRating = AgeRating.Unknown;
|
||||||
@Input({required: true}) libraryType!: LibraryType;
|
@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"
|
[ratings]="ratings"
|
||||||
[userRating]="series.userRating"
|
[userRating]="series.userRating"
|
||||||
[hasUserRated]="series.hasUserRated"
|
[hasUserRated]="series.hasUserRated"
|
||||||
[libraryType]="libraryType">
|
[libraryType]="libraryType"
|
||||||
|
[webLinks]="WebLinks">
|
||||||
</app-external-rating>
|
</app-external-rating>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -127,89 +128,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
<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>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2 upper-details">
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<span class="fw-bold">{{t('writers-title')}}</span>
|
<span class="fw-bold">{{t('writers-title')}}</span>
|
||||||
<div>
|
<div>
|
||||||
<app-badge-expander [items]="seriesMetadata.writers">
|
<app-badge-expander [items]="seriesMetadata.writers"
|
||||||
|
[itemsTillExpander]="3"
|
||||||
|
[allowToggle]="false">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Writers, item.id)">{{item.name}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openFilter(FilterField.Writers, item.id)">{{item.name}}</a>
|
||||||
@if (!last) {
|
|
||||||
,
|
|
||||||
}
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="col-6">
|
||||||
<span class="fw-bold">{{t('publication-status-title')}}</span>
|
<span class="fw-bold">{{t('publication-status-title')}}</span>
|
||||||
<div>
|
<div>
|
||||||
@ -225,6 +160,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@ -513,7 +510,7 @@
|
|||||||
<a ngbNavLink>{{t(TabID.Details)}}</a>
|
<a ngbNavLink>{{t(TabID.Details)}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@defer (when activeTabId === TabID.Details; prefetch on idle) {
|
@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>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
@ -39,3 +39,14 @@
|
|||||||
background-color: var(--primary-color-dark-shade);
|
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);
|
this.actionService.scanSeries(series);
|
||||||
break;
|
break;
|
||||||
case(Action.RefreshMetadata):
|
case(Action.RefreshMetadata):
|
||||||
this.actionService.refreshSeriesMetadata(series);
|
this.actionService.refreshSeriesMetadata(series, undefined, true);
|
||||||
break;
|
break;
|
||||||
case(Action.GenerateColorScape):
|
case(Action.GenerateColorScape):
|
||||||
this.actionService.refreshSeriesMetadata(series, undefined, false);
|
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 { PaginatedResult } from 'src/app/_models/pagination';
|
||||||
import { Series } from 'src/app/_models/series';
|
import { Series } from 'src/app/_models/series';
|
||||||
import { Volume } from 'src/app/_models/volume';
|
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 {
|
export enum KEY_CODES {
|
||||||
RIGHT_ARROW = 'ArrowRight',
|
RIGHT_ARROW = 'ArrowRight',
|
||||||
@ -37,9 +38,10 @@ export enum Breakpoint {
|
|||||||
})
|
})
|
||||||
export class UtilityService {
|
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) => {
|
sortChapters = (a: Chapter, b: Chapter) => {
|
||||||
@ -68,16 +70,16 @@ export class UtilityService {
|
|||||||
switch(libraryType) {
|
switch(libraryType) {
|
||||||
case LibraryType.Book:
|
case LibraryType.Book:
|
||||||
case LibraryType.LightNovel:
|
case LibraryType.LightNovel:
|
||||||
return this.translocoService.translate('common.book-num' + extra) + (includeSpace ? ' ' : '');
|
return translate('common.book-num' + extra) + (includeSpace ? ' ' : '');
|
||||||
case LibraryType.Comic:
|
case LibraryType.Comic:
|
||||||
case LibraryType.ComicVine:
|
case LibraryType.ComicVine:
|
||||||
if (includeHash) {
|
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.Images:
|
||||||
case LibraryType.Manga:
|
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">
|
<div class="content">
|
||||||
@for(item of visibleItems; track item; let i = $index; let last = $last) {
|
@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>
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i, last: last }"></ng-container>
|
||||||
|
@if (!last) {
|
||||||
|
<span>, </span>
|
||||||
|
}
|
||||||
} @empty {
|
} @empty {
|
||||||
{{null | defaultValue}}
|
{{null | defaultValue}}
|
||||||
}
|
}
|
||||||
@if (!isCollapsed && itemsLeft !== 0) {
|
@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})}}
|
{{t('more-items', {count: itemsLeft})}}
|
||||||
</button>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -26,6 +26,7 @@ export class BadgeExpanderComponent implements OnInit {
|
|||||||
|
|
||||||
@Input() items: Array<any> = [];
|
@Input() items: Array<any> = [];
|
||||||
@Input() itemsTillExpander: number = 4;
|
@Input() itemsTillExpander: number = 4;
|
||||||
|
@Input() allowToggle: boolean = true;
|
||||||
@ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef<any>;
|
@ContentChild('badgeExpanderItem') itemTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
|
|
||||||
@ -42,6 +43,8 @@ export class BadgeExpanderComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleVisible() {
|
toggleVisible() {
|
||||||
|
if (!this.allowToggle) return;
|
||||||
|
|
||||||
this.isCollapsed = !this.isCollapsed;
|
this.isCollapsed = !this.isCollapsed;
|
||||||
this.visibleItems = this.items;
|
this.visibleItems = this.items;
|
||||||
this.cdRef.markForCheck();
|
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 {ImageService} from 'src/app/_services/image.service';
|
||||||
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service';
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
import {CommonModule, NgOptimizedImage} from "@angular/common";
|
|
||||||
import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
|
import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,7 +23,7 @@ import {LazyLoadImageModule, StateChange} from "ng-lazyload-image";
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-image',
|
selector: 'app-image',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, NgOptimizedImage, LazyLoadImageModule],
|
imports: [LazyLoadImageModule],
|
||||||
templateUrl: './image.component.html',
|
templateUrl: './image.component.html',
|
||||||
styleUrls: ['./image.component.scss'],
|
styleUrls: ['./image.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@ -62,10 +61,13 @@ export class ImageComponent implements OnChanges {
|
|||||||
*/
|
*/
|
||||||
@Input() styles: {[key: string]: string} = {};
|
@Input() styles: {[key: string]: string} = {};
|
||||||
@Input() errorImage: string = this.imageService.errorImage;
|
@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>;
|
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
|
||||||
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||||
if (!this.processEvents) return;
|
if (!this.processEvents) return;
|
||||||
@ -138,6 +140,9 @@ export class ImageComponent implements OnChanges {
|
|||||||
// The image could not be loaded for some reason.
|
// The image could not be loaded for some reason.
|
||||||
// `event.data` is the error in this case
|
// `event.data` is the error in this case
|
||||||
this.renderer.removeClass(image, 'fade-in');
|
this.renderer.removeClass(image, 'fade-in');
|
||||||
|
if (this.hideOnError) {
|
||||||
|
this.renderer.addClass(image, 'd-none');
|
||||||
|
}
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
break;
|
break;
|
||||||
case 'finally':
|
case 'finally':
|
||||||
|
@ -106,16 +106,22 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||||||
new SideNavItem(SettingsTabId.General, [Role.Admin]),
|
new SideNavItem(SettingsTabId.General, [Role.Admin]),
|
||||||
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
|
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
|
||||||
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
|
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
|
||||||
new SideNavItem(SettingsTabId.Statistics, [Role.Admin]),
|
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
|
||||||
new SideNavItem(SettingsTabId.System, [Role.Admin]),
|
new SideNavItem(SettingsTabId.Libraries, [Role.Admin]),
|
||||||
|
new SideNavItem(SettingsTabId.Tasks, [Role.Admin]),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'manage-section-title',
|
title: 'import-section-title',
|
||||||
children: [
|
children: [
|
||||||
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
|
new SideNavItem(SettingsTabId.CBLImport, []),
|
||||||
new SideNavItem(SettingsTabId.Libraries, [Role.Admin]),
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'info-section-title',
|
||||||
|
children: [
|
||||||
|
new SideNavItem(SettingsTabId.System, [Role.Admin]),
|
||||||
|
new SideNavItem(SettingsTabId.Statistics, [Role.Admin]),
|
||||||
new SideNavItem(SettingsTabId.MediaIssues, [Role.Admin],
|
new SideNavItem(SettingsTabId.MediaIssues, [Role.Admin],
|
||||||
this.accountService.currentUser$.pipe(
|
this.accountService.currentUser$.pipe(
|
||||||
take(1),
|
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">
|
<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>
|
<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'}}%">
|
<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>
|
<ngb-progressbar type="primary" height="5px" [value]="volume.pagesRead" [max]="volume.pages" [showValue]="true"></ngb-progressbar>
|
||||||
</div>
|
</div>
|
||||||
@ -20,13 +20,13 @@
|
|||||||
<div class="subtitle mt-2 mb-2">
|
<div class="subtitle mt-2 mb-2">
|
||||||
<span>
|
<span>
|
||||||
{{t('volume-num')}}
|
{{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-metadata-detail-row [entity]="volumeCast"
|
<app-metadata-detail-row [entity]="volumeCast"
|
||||||
[ageRating]="maxAgeRating"
|
[ageRating]="maxAgeRating"
|
||||||
[hasReadingProgress]="hasReadingProgress"
|
[hasReadingProgress]="volume.pagesRead > 0"
|
||||||
[readingTimeEntity]="volume"
|
[readingTimeEntity]="volume"
|
||||||
[libraryType]="libraryType">
|
[libraryType]="libraryType">
|
||||||
</app-metadata-detail-row>
|
</app-metadata-detail-row>
|
||||||
@ -49,8 +49,8 @@
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-primary-outline" (click)="readVolume()">
|
<button type="button" class="btn btn-primary-outline" (click)="readVolume()">
|
||||||
<span>
|
<span>
|
||||||
<i class="fa {{hasReadingProgress ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
<i class="fa {{volume.pagesRead > 0 ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
<span class="read-btn--text"> {{(volume.pagesRead > 0) ? t('continue') : t('read')}}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<button ngbDropdownItem (click)="readVolume(true)">
|
<button ngbDropdownItem (click)="readVolume(true)">
|
||||||
<span>
|
<span>
|
||||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
|
<span class="read-btn--text"> {{(volume.pagesRead > 0) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -85,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 mb-3">
|
<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>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@ -96,9 +96,6 @@
|
|||||||
<app-badge-expander [items]="volumeCast.writers">
|
<app-badge-expander [items]="volumeCast.writers">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.Writers, item.id)">{{item.name}}</a>
|
||||||
@if (!last) {
|
|
||||||
,
|
|
||||||
}
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
@ -109,9 +106,6 @@
|
|||||||
<app-badge-expander [items]="volumeCast.coverArtists">
|
<app-badge-expander [items]="volumeCast.coverArtists">
|
||||||
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
<ng-template #badgeExpanderItem let-item let-position="idx" let-last="last">
|
||||||
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
<a href="javascript:void(0)" class="dark-exempt btn-icon" (click)="openPerson(FilterField.CoverArtist, item.id)">{{item.name}}</a>
|
||||||
@if (!last) {
|
|
||||||
,
|
|
||||||
}
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-badge-expander>
|
</app-badge-expander>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,7 +59,7 @@ import {ImageComponent} from "../shared/image/image.component";
|
|||||||
import {CardItemComponent} from "../cards/card-item/card-item.component";
|
import {CardItemComponent} from "../cards/card-item/card-item.component";
|
||||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||||
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
|
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 {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
|
||||||
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
|
import {DefaultValuePipe} from "../_pipes/default-value.pipe";
|
||||||
import {
|
import {
|
||||||
@ -196,7 +196,6 @@ export class VolumeDetailComponent implements OnInit {
|
|||||||
volume: Volume | null = null;
|
volume: Volume | null = null;
|
||||||
series: Series | null = null;
|
series: Series | null = null;
|
||||||
libraryType: LibraryType | null = null;
|
libraryType: LibraryType | null = null;
|
||||||
hasReadingProgress = false;
|
|
||||||
activeTabId = TabID.Chapters;
|
activeTabId = TabID.Chapters;
|
||||||
readingLists: ReadingList[] = [];
|
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",
|
"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.",
|
"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.",
|
"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",
|
"no-restriction": "No Restriction",
|
||||||
"include-unknowns-label": "Include Unknowns",
|
"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."
|
"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",
|
"publishers-title": "Publishers",
|
||||||
"imprints-title": "Imprints",
|
"imprints-title": "Imprints",
|
||||||
"teams-title": "Teams",
|
"teams-title": "Teams",
|
||||||
"locations-title": "Locations"
|
"locations-title": "Locations",
|
||||||
|
"language-title": "Language",
|
||||||
|
"age-rating-title": "Age Rating"
|
||||||
},
|
},
|
||||||
|
|
||||||
"download-button": {
|
"download-button": {
|
||||||
@ -857,7 +859,8 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"external-rating": {
|
"external-rating": {
|
||||||
"entry-label": "See Details"
|
"entry-label": "See Details",
|
||||||
|
"kavita-tooltip": "Your Rating + Overall"
|
||||||
},
|
},
|
||||||
|
|
||||||
"badge-expander": {
|
"badge-expander": {
|
||||||
@ -1052,21 +1055,22 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"details-tab": {
|
"details-tab": {
|
||||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
"writers-title": "{{metadata-fields.writers-title}}",
|
||||||
"publishers-title": "{{series-metadata-detail.publishers-title}}",
|
"publishers-title": "{{metadata-fields.publishers-title}}",
|
||||||
"characters-title": "{{series-metadata-detail.characters-title}}",
|
"characters-title": "{{metadata-fields.characters-title}}",
|
||||||
"translators-title": "{{series-metadata-detail.translators-title}}",
|
"translators-title": "{{metadata-fields.translators-title}}",
|
||||||
"letterers-title": "{{series-metadata-detail.letterers-title}}",
|
"letterers-title": "{{metadata-fields.letterers-title}}",
|
||||||
"colorists-title": "{{series-metadata-detail.colorists-title}}",
|
"colorists-title": "{{metadata-fields.colorists-title}}",
|
||||||
"inkers-title": "{{series-metadata-detail.inkers-title}}",
|
"inkers-title": "{{metadata-fields.inkers-title}}",
|
||||||
"pencillers-title": "{{series-metadata-detail.pencillers-title}}",
|
"pencillers-title": "{{metadata-fields.pencillers-title}}",
|
||||||
"cover-artists-title": "{{series-metadata-detail.cover-artists-title}}",
|
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
|
||||||
"editors-title": "{{series-metadata-detail.editors-title}}",
|
"editors-title": "{{metadata-fields.editors-title}}",
|
||||||
"teams-title": "{{series-metadata-detail.teams-title}}",
|
"teams-title": "{{metadata-fields.teams-title}}",
|
||||||
"locations-title": "{{series-metadata-detail.locations-title}}",
|
"locations-title": "{{metadata-fields.locations-title}}",
|
||||||
"imprints-title": "{{series-metadata-detail.imprints-title}}",
|
"imprints-title": "{{metadata-fields.imprints-title}}",
|
||||||
"genres-title": "{{series-metadata-detail.genres-title}}",
|
"genres-title": "{{metadata-fields.genres-title}}",
|
||||||
"tags-title": "{{series-metadata-detail.tags-title}}"
|
"tags-title": "{{metadata-fields.tags-title}}",
|
||||||
|
"weblinks-title": "{{tabs.weblink-tab}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"related-tab": {
|
"related-tab": {
|
||||||
@ -1075,18 +1079,18 @@
|
|||||||
|
|
||||||
"chapter-metadata-detail": {
|
"chapter-metadata-detail": {
|
||||||
"no-data": "No metadata available",
|
"no-data": "No metadata available",
|
||||||
"writers-title": "{{series-metadata-detail.writers-title}}",
|
"writers-title": "{{metadata-fields.writers-title}}",
|
||||||
"publishers-title": "{{series-metadata-detail.publishers-title}}",
|
"publishers-title": "{{metadata-fields.publishers-title}}",
|
||||||
"characters-title": "{{series-metadata-detail.characters-title}}",
|
"characters-title": "{{metadata-fields.characters-title}}",
|
||||||
"translators-title": "{{series-metadata-detail.translators-title}}",
|
"translators-title": "{{metadata-fields.translators-title}}",
|
||||||
"letterers-title": "{{series-metadata-detail.letterers-title}}",
|
"letterers-title": "{{metadata-fields.letterers-title}}",
|
||||||
"colorists-title": "{{series-metadata-detail.colorists-title}}",
|
"colorists-title": "{{metadata-fields.colorists-title}}",
|
||||||
"inkers-title": "{{series-metadata-detail.inkers-title}}",
|
"inkers-title": "{{metadata-fields.inkers-title}}",
|
||||||
"pencillers-title": "{{series-metadata-detail.pencillers-title}}",
|
"pencillers-title": "{metadata-fields.pencillers-title}}",
|
||||||
"cover-artists-title": "{{series-metadata-detail.cover-artists-title}}",
|
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
|
||||||
"editors-title": "{{series-metadata-detail.editors-title}}",
|
"editors-title": "{{metadata-fields.editors-title}}",
|
||||||
"teams-title": "{{series-metadata-detail.teams-title}}",
|
"teams-title": "{{metadata-fields.teams-title}}",
|
||||||
"locations-title": "{{series-metadata-detail.locations-title}}"
|
"locations-title": "{{metadata-fields.locations-title}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"cover-image-chooser": {
|
"cover-image-chooser": {
|
||||||
@ -1120,11 +1124,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"entity-info-cards": {
|
"entity-info-cards": {
|
||||||
"tags-title": "{{series-metadata-detail.tags-title}}",
|
"tags-title": "{{metadata-fields.tags-title}}",
|
||||||
"characters-title": "{{series-metadata-detail.characters-title}}",
|
"characters-title": "{{metadata-fields.characters-title}}",
|
||||||
"release-date-title": "Release",
|
"release-date-title": "Release",
|
||||||
"release-date-tooltip": "Release Date",
|
"release-date-tooltip": "Release Date",
|
||||||
"age-rating-title": "Age Rating",
|
"age-rating-title": "{{metadata-fields.age-rating-title}}",
|
||||||
"length-title": "Length",
|
"length-title": "Length",
|
||||||
"pages-count": "{{num}} Pages",
|
"pages-count": "{{num}} Pages",
|
||||||
"words-count": "{{num}} Words",
|
"words-count": "{{num}} Words",
|
||||||
@ -1133,7 +1137,7 @@
|
|||||||
"date-added-title": "Date Added",
|
"date-added-title": "Date Added",
|
||||||
"size-title": "Size",
|
"size-title": "Size",
|
||||||
"id-title": "ID",
|
"id-title": "ID",
|
||||||
"links-title": "{{series-metadata-detail.links-title}}",
|
"links-title": "{{metadata-fields.links-title}}",
|
||||||
"isbn-title": "ISBN",
|
"isbn-title": "ISBN",
|
||||||
"sort-order-title": "Sort Order",
|
"sort-order-title": "Sort Order",
|
||||||
"last-read-title": "Last Read",
|
"last-read-title": "Last Read",
|
||||||
@ -1148,7 +1152,7 @@
|
|||||||
"release-date-title": "{{entity-info-cards.release-date-title}}",
|
"release-date-title": "{{entity-info-cards.release-date-title}}",
|
||||||
"release-year-tooltip": "Release Year",
|
"release-year-tooltip": "Release Year",
|
||||||
"age-rating-title": "{{entity-info-cards.age-rating-title}}",
|
"age-rating-title": "{{entity-info-cards.age-rating-title}}",
|
||||||
"language-title": "Language",
|
"language-title": "{{metadata-fields.language-title}}",
|
||||||
"publication-status-title": "Publication",
|
"publication-status-title": "Publication",
|
||||||
"publication-status-tooltip": "Publication Status",
|
"publication-status-tooltip": "Publication Status",
|
||||||
"scrobbling-title": "Scrobbling",
|
"scrobbling-title": "Scrobbling",
|
||||||
@ -1514,7 +1518,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"account-section-title": "Account",
|
"account-section-title": "Account",
|
||||||
"server-section-title": "Server",
|
"server-section-title": "Server",
|
||||||
"manage-section-title": "Manage",
|
"info-section-title": "Info",
|
||||||
"import-section-title": "Import",
|
"import-section-title": "Import",
|
||||||
"kavitaplus-section-title": "{{settings.admin-kavitaplus}}",
|
"kavitaplus-section-title": "{{settings.admin-kavitaplus}}",
|
||||||
"admin-general": "General",
|
"admin-general": "General",
|
||||||
@ -1601,7 +1605,7 @@
|
|||||||
"read-options-alt": "Read options",
|
"read-options-alt": "Read options",
|
||||||
"incognito-alt": "(Incognito)",
|
"incognito-alt": "(Incognito)",
|
||||||
"no-data": "Nothing added",
|
"no-data": "Nothing added",
|
||||||
"characters-title": "{{series-metadata-detail.characters-title}}"
|
"characters-title": "{{metadata-fields.characters-title}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"events-widget": {
|
"events-widget": {
|
||||||
@ -1854,8 +1858,8 @@
|
|||||||
"read": "Read",
|
"read": "Read",
|
||||||
"in-progress": "In Progress",
|
"in-progress": "In Progress",
|
||||||
"rating-label": "Rating",
|
"rating-label": "Rating",
|
||||||
"age-rating-label": "Age Rating",
|
"age-rating-label": "{{metadata-fields.age-rating-title}}",
|
||||||
"language-label": "Language",
|
"language-label": "{{metadata-fields.language-title}}",
|
||||||
"publication-status-label": "Publication Status",
|
"publication-status-label": "Publication Status",
|
||||||
"series-name-label": "Series Name",
|
"series-name-label": "Series Name",
|
||||||
"series-name-tooltip": "Series name will filter against Name, Sort Name, or Localized 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}}",
|
"genres-label": "{{metadata-fields.genres-title}}",
|
||||||
"tags-label": "{{metadata-fields.tags-title}}",
|
"tags-label": "{{metadata-fields.tags-title}}",
|
||||||
"cover-artist-label": "Cover Artist",
|
"cover-artist-label": "{{metadata-fields.cover-artists-title}}",
|
||||||
"writer-label": "Writer",
|
"writer-label": "{{metadata-fields.writers-title}}",
|
||||||
"publisher-label": "Publisher",
|
"publisher-label": "{{metadata-fields.publishers-title}}",
|
||||||
"imprint-label": "Imprint",
|
"imprint-label": "{{metadata-fields.imprints-title}}",
|
||||||
"penciller-label": "Penciller",
|
"penciller-label": "{{metadata-fields.pencillers-title}}",
|
||||||
"letterer-label": "Letterer",
|
"letterer-label": "{{metadata-fields.letterers-title}}",
|
||||||
"inker-label": "Inker",
|
"inker-label": "{{metadata-fields.inkers-title}}",
|
||||||
"editor-label": "Editor",
|
"editor-label": "{{metadata-fields.editors-title}}",
|
||||||
"colorist-label": "Colorist",
|
"colorist-label": "{{metadata-fields.colorists-title}}",
|
||||||
"character-label": "Character",
|
"character-label": "{{metadata-fields.characters-title}}",
|
||||||
"translator-label": "Translator",
|
"translator-label": "{{metadata-fields.translators-title}}",
|
||||||
"team-label": "{{filter-field-pipe.team}}",
|
"team-label": "{{metadata-fields.teams-title}}",
|
||||||
"location-label": "{{filter-field-pipe.location}}",
|
"location-label": "{{metadata-fields.locations-title}}",
|
||||||
"language-label": "Language",
|
"language-label": "{{metadata-fields.language-title}}",
|
||||||
"age-rating-label": "Age Rating",
|
"age-rating-label": "{{metadata-fields.age-rating-title}}",
|
||||||
|
|
||||||
"publication-status-label": "Publication Status",
|
"publication-status-label": "Publication Status",
|
||||||
"required-field": "{{validation.required-field}}",
|
"required-field": "{{validation.required-field}}",
|
||||||
@ -1920,7 +1924,7 @@
|
|||||||
"summary-label": "Summary",
|
"summary-label": "Summary",
|
||||||
"release-year-label": "Release Year",
|
"release-year-label": "Release Year",
|
||||||
"web-link-description": "Here you can add many different links to external services.",
|
"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.",
|
"cover-image-description": "Upload and choose a new cover image. Press Save to upload and override the cover.",
|
||||||
"save": "{{common.save}}",
|
"save": "{{common.save}}",
|
||||||
"field-locked-alt": "Field is locked",
|
"field-locked-alt": "Field is locked",
|
||||||
@ -1937,6 +1941,7 @@
|
|||||||
"lowest-folder-path-tooltip": "Lowest path from library root that contains all series files",
|
"lowest-folder-path-tooltip": "Lowest path from library root that contains all series files",
|
||||||
"publication-status-title": "Publication Status",
|
"publication-status-title": "Publication Status",
|
||||||
"total-pages-title": "Total Pages",
|
"total-pages-title": "Total Pages",
|
||||||
|
"total-words-title": "Total Words",
|
||||||
"total-items-title": "Total Items",
|
"total-items-title": "Total Items",
|
||||||
"max-items-title": "Max Items",
|
"max-items-title": "Max Items",
|
||||||
"size-title": "Size",
|
"size-title": "Size",
|
||||||
@ -1953,7 +1958,8 @@
|
|||||||
"force-refresh": "Force Refresh",
|
"force-refresh": "Force Refresh",
|
||||||
"force-refresh-tooltip": "Force refresh external metadata from Kavita+",
|
"force-refresh-tooltip": "Force refresh external metadata from Kavita+",
|
||||||
"loose-leaf-volume": "Loose Leaf Chapters",
|
"loose-leaf-volume": "Loose Leaf Chapters",
|
||||||
"specials-volume": "Specials"
|
"specials-volume": "Specials",
|
||||||
|
"release-year-validation": "{{validation.year-validation}}"
|
||||||
},
|
},
|
||||||
|
|
||||||
"edit-chapter-modal": {
|
"edit-chapter-modal": {
|
||||||
@ -2253,7 +2259,7 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"filter-field-pipe": {
|
"filter-field-pipe": {
|
||||||
"age-rating": "Age Rating",
|
"age-rating": "{{metadata-fields.age-rating-title}}",
|
||||||
"characters": "{{metadata-fields.characters-title}}",
|
"characters": "{{metadata-fields.characters-title}}",
|
||||||
"collection-tags": "Collection Tags",
|
"collection-tags": "Collection Tags",
|
||||||
"colorist": "Colorist",
|
"colorist": "Colorist",
|
||||||
@ -2477,7 +2483,6 @@
|
|||||||
"import-mal-stack": "Import MAL Stack",
|
"import-mal-stack": "Import MAL Stack",
|
||||||
"import-mal-stack-tooltip": "Creates a Smart Collection from your MAL Interest Stacks",
|
"import-mal-stack-tooltip": "Creates a Smart Collection from your MAL Interest Stacks",
|
||||||
"read": "Read",
|
"read": "Read",
|
||||||
"read-tooltip": "",
|
|
||||||
"customize": "Customize",
|
"customize": "Customize",
|
||||||
"customize-tooltip": "TODO",
|
"customize-tooltip": "TODO",
|
||||||
"mark-visible": "Mark as Visible",
|
"mark-visible": "Mark as Visible",
|
||||||
@ -2531,7 +2536,8 @@
|
|||||||
"validation": {
|
"validation": {
|
||||||
"required-field": "This field is required",
|
"required-field": "This field is required",
|
||||||
"valid-email": "This must be a valid email",
|
"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": {
|
"entity-type": {
|
||||||
|
@ -87,7 +87,6 @@ label, select, .clickable {
|
|||||||
app-root {
|
app-root {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--primary-color-scrollbar);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
@ -96,7 +95,6 @@ app-root {
|
|||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-clip: padding-box;
|
background-clip: padding-box;
|
||||||
background-color: var(--primary-color-scrollbar);
|
|
||||||
border: 3px solid transparent;
|
border: 3px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
@ -109,6 +107,6 @@ body {
|
|||||||
|
|
||||||
.setting-section-break {
|
.setting-section-break {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
background-color: var(--setting-break-color);
|
||||||
margin: 30px 0;
|
margin: 30px 0;
|
||||||
}
|
}
|