mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-02-07 11:33:31 -05:00
Epub Text Bleeding Finally Fixed! (#4086)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com> Co-authored-by: Gazy Mahomar <gmahomarf@users.noreply.github.com> Co-authored-by: Stefans.A <104719225+privatestefans@users.noreply.github.com>
This commit is contained in:
parent
75e844404c
commit
f7dca3806f
@ -35,7 +35,8 @@ public class ScannerHelper
|
||||
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");
|
||||
private static readonly string[] ComicInfoExtensions = new[] { ".cbz", ".cbr", ".zip", ".rar" };
|
||||
private static readonly string[] ComicInfoExtensions = [".cbz", ".cbr", ".zip", ".rar"];
|
||||
private static readonly string[] EpubExtensions = [".epub"];
|
||||
|
||||
public ScannerHelper(IUnitOfWork unitOfWork, ITestOutputHelper testOutputHelper)
|
||||
{
|
||||
@ -43,7 +44,7 @@ public class ScannerHelper
|
||||
_testOutputHelper = testOutputHelper;
|
||||
}
|
||||
|
||||
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo> comicInfos = null)
|
||||
public async Task<Library> GenerateScannerData(string testcase, Dictionary<string, ComicInfo>? comicInfos = null)
|
||||
{
|
||||
var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase), comicInfos);
|
||||
|
||||
@ -113,7 +114,7 @@ public class ScannerHelper
|
||||
|
||||
|
||||
|
||||
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo> comicInfos = null)
|
||||
private async Task<string> GenerateTestDirectory(string mapPath, Dictionary<string, ComicInfo>? comicInfos = null)
|
||||
{
|
||||
// Read the map file
|
||||
var mapContent = await File.ReadAllTextAsync(mapPath);
|
||||
@ -130,7 +131,7 @@ public class ScannerHelper
|
||||
Directory.CreateDirectory(testDirectory);
|
||||
|
||||
// Generate the files and folders
|
||||
await Scaffold(testDirectory, filePaths, comicInfos);
|
||||
await Scaffold(testDirectory, filePaths ?? [], comicInfos);
|
||||
|
||||
_testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}");
|
||||
|
||||
@ -138,7 +139,7 @@ public class ScannerHelper
|
||||
}
|
||||
|
||||
|
||||
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo> comicInfos = null)
|
||||
public async Task Scaffold(string testDirectory, List<string> filePaths, Dictionary<string, ComicInfo>? comicInfos = null)
|
||||
{
|
||||
foreach (var relativePath in filePaths)
|
||||
{
|
||||
@ -157,6 +158,10 @@ public class ScannerHelper
|
||||
{
|
||||
CreateMinimalCbz(fullPath, info);
|
||||
}
|
||||
else if (EpubExtensions.Contains(ext) && comicInfos != null && comicInfos.TryGetValue(Path.GetFileName(relativePath), out var epubInfo))
|
||||
{
|
||||
CreateMinimalEpub(fullPath, epubInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create an empty file
|
||||
@ -205,4 +210,165 @@ public class ScannerHelper
|
||||
return stringWriter.ToString().Replace("""<?xml version="1.0" encoding="utf-16"?>""",
|
||||
@"<?xml version='1.0' encoding='utf-8'?>");
|
||||
}
|
||||
|
||||
private void CreateMinimalEpub(string filePath, ComicInfo? comicInfo = null)
|
||||
{
|
||||
using (var archive = ZipFile.Open(filePath, ZipArchiveMode.Create))
|
||||
{
|
||||
// EPUB requires a mimetype file as the first entry (uncompressed)
|
||||
var mimetypeEntry = archive.CreateEntry("mimetype", CompressionLevel.NoCompression);
|
||||
using (var mimetypeStream = mimetypeEntry.Open())
|
||||
using (var writer = new StreamWriter(mimetypeStream, Encoding.ASCII))
|
||||
{
|
||||
writer.Write("application/epub+zip");
|
||||
}
|
||||
|
||||
// Create META-INF/container.xml
|
||||
var containerEntry = archive.CreateEntry("META-INF/container.xml");
|
||||
using (var containerStream = containerEntry.Open())
|
||||
using (var writer = new StreamWriter(containerStream, Encoding.UTF8))
|
||||
{
|
||||
writer.Write("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>
|
||||
""");
|
||||
}
|
||||
|
||||
// Create content.opf with metadata
|
||||
var contentOpf = GenerateContentOpf(comicInfo);
|
||||
var contentEntry = archive.CreateEntry("OEBPS/content.opf");
|
||||
using (var contentStream = contentEntry.Open())
|
||||
using (var writer = new StreamWriter(contentStream, Encoding.UTF8))
|
||||
{
|
||||
writer.Write(contentOpf);
|
||||
}
|
||||
|
||||
// Add a minimal chapter XHTML file
|
||||
var chapterEntry = archive.CreateEntry("OEBPS/chapter1.xhtml");
|
||||
using (var chapterStream = chapterEntry.Open())
|
||||
using (var writer = new StreamWriter(chapterStream, Encoding.UTF8))
|
||||
{
|
||||
writer.Write("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>Chapter 1</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Test content.</p>
|
||||
</body>
|
||||
</html>
|
||||
""");
|
||||
}
|
||||
|
||||
// Add the cover image
|
||||
archive.CreateEntryFromFile(_imagePath, "OEBPS/cover.png");
|
||||
}
|
||||
Console.WriteLine($"Created minimal EPUB archive: {filePath} with{(comicInfo != null ? "" : "out")} metadata.");
|
||||
}
|
||||
|
||||
private static string GenerateContentOpf(ComicInfo? comicInfo)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("""<?xml version="1.0" encoding="UTF-8"?>""");
|
||||
sb.AppendLine("""<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id">""");
|
||||
|
||||
// Metadata section
|
||||
sb.AppendLine(" <metadata xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:opf=\"http://www.idpf.org/2007/opf\" xmlns:calibre=\"http://calibre.kovidgoyal.net/2009/metadata\">");
|
||||
|
||||
if (comicInfo != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(comicInfo.Title))
|
||||
sb.AppendLine($" <dc:title>{EscapeXml(comicInfo.Title)}</dc:title>");
|
||||
else
|
||||
sb.AppendLine(" <dc:title>Untitled</dc:title>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Series))
|
||||
{
|
||||
sb.AppendLine($" <meta property=\"belongs-to-collection\" id=\"collection\">{EscapeXml(comicInfo.Series)}</meta>");
|
||||
sb.AppendLine(" <meta refines=\"#collection\" property=\"collection-type\">series</meta>");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Writer))
|
||||
sb.AppendLine($" <dc:creator opf:role=\"aut\">{EscapeXml(comicInfo.Writer)}</dc:creator>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Publisher))
|
||||
sb.AppendLine($" <dc:publisher>{EscapeXml(comicInfo.Publisher)}</dc:publisher>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Summary))
|
||||
sb.AppendLine($" <dc:description>{EscapeXml(comicInfo.Summary)}</dc:description>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.LanguageISO))
|
||||
sb.AppendLine($" <dc:language>{EscapeXml(comicInfo.LanguageISO)}</dc:language>");
|
||||
else
|
||||
sb.AppendLine(" <dc:language>en</dc:language>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Isbn))
|
||||
sb.AppendLine($" <dc:identifier id=\"book-id\" opf:scheme=\"ISBN\">{EscapeXml(comicInfo.Isbn)}</dc:identifier>");
|
||||
else
|
||||
sb.AppendLine($" <dc:identifier id=\"book-id\">urn:uuid:{Guid.NewGuid()}</dc:identifier>");
|
||||
|
||||
if (comicInfo.Year > 0)
|
||||
{
|
||||
var date = $"{comicInfo.Year:D4}";
|
||||
if (comicInfo.Month > 0)
|
||||
{
|
||||
date += $"-{comicInfo.Month:D2}";
|
||||
if (comicInfo.Day > 0)
|
||||
date += $"-{comicInfo.Day:D2}";
|
||||
}
|
||||
sb.AppendLine($" <dc:date>{date}</dc:date>");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.TitleSort))
|
||||
sb.AppendLine($" <meta name=\"calibre:title_sort\" content=\"{EscapeXml(comicInfo.TitleSort)}\"/>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.SeriesSort))
|
||||
sb.AppendLine($" <meta name=\"calibre:series_sort\" content=\"{EscapeXml(comicInfo.SeriesSort)}\"/>");
|
||||
|
||||
if (!string.IsNullOrEmpty(comicInfo.Number))
|
||||
sb.AppendLine($" <meta name=\"calibre:series_index\" content=\"{EscapeXml(comicInfo.Number)}\"/>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(" <dc:title>Untitled</dc:title>");
|
||||
sb.AppendLine(" <dc:language>en</dc:language>");
|
||||
sb.AppendLine($" <dc:identifier id=\"book-id\">urn:uuid:{Guid.NewGuid()}</dc:identifier>");
|
||||
}
|
||||
|
||||
sb.AppendLine(" </metadata>");
|
||||
|
||||
// Manifest section
|
||||
sb.AppendLine(" <manifest>");
|
||||
sb.AppendLine(" <item id=\"chapter1\" href=\"chapter1.xhtml\" media-type=\"application/xhtml+xml\"/>");
|
||||
sb.AppendLine(" <item id=\"cover\" href=\"cover.png\" media-type=\"image/png\" properties=\"cover-image\"/>");
|
||||
sb.AppendLine(" </manifest>");
|
||||
|
||||
// Spine section
|
||||
sb.AppendLine(" <spine>");
|
||||
sb.AppendLine(" <itemref idref=\"chapter1\"/>");
|
||||
sb.AppendLine(" </spine>");
|
||||
|
||||
sb.AppendLine("</package>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string EscapeXml(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return text;
|
||||
|
||||
return text
|
||||
.Replace("&", "&")
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\"", """)
|
||||
.Replace("'", "'");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -133,7 +133,6 @@ public class SettingsController : BaseApiController
|
||||
/// Is the minimum information setup for Email to work
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("is-email-setup")]
|
||||
public async Task<ActionResult<bool>> IsEmailSetup()
|
||||
{
|
||||
|
||||
@ -120,6 +120,7 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
||||
existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
|
||||
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
|
||||
existingPreferences.DataSaver = preferencesDto.DataSaver;
|
||||
|
||||
var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id))
|
||||
.Select(l => l.Id).ToList();
|
||||
|
||||
@ -11,6 +11,7 @@ public sealed record ReadingListItemDto
|
||||
public int ChapterId { get; init; }
|
||||
public int SeriesId { get; init; }
|
||||
public string? SeriesName { get; set; }
|
||||
public string? SeriesSortName { get; set; }
|
||||
public MangaFormat SeriesFormat { get; set; }
|
||||
public int PagesRead { get; set; }
|
||||
public int PagesTotal { get; set; }
|
||||
|
||||
@ -37,6 +37,9 @@ public sealed record UserPreferencesDto
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.ColorScapeEnabled"/>
|
||||
[Required]
|
||||
public bool ColorScapeEnabled { get; set; } = true;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.DataSaver"/>
|
||||
[Required]
|
||||
public bool DataSaver { get; set; } = false;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.AniListScrobblingEnabled"/>
|
||||
public bool AniListScrobblingEnabled { get; set; }
|
||||
|
||||
3928
API/Data/Migrations/20251009150922_DataSaverUserSetting.Designer.cs
generated
Normal file
3928
API/Data/Migrations/20251009150922_DataSaverUserSetting.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20251009150922_DataSaverUserSetting.cs
Normal file
29
API/Data/Migrations/20251009150922_DataSaverUserSetting.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DataSaverUserSetting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "DataSaver",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DataSaver",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -571,6 +571,9 @@ namespace API.Data.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("DataSaver")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EmulateBook")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@ -311,7 +311,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
.Where(l => l.AppUserId == userId || (includePromoted && l.Promoted ))
|
||||
.RestrictAgainstAgeRestriction(user.GetAgeRestriction());
|
||||
|
||||
query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.NormalizedTitle);
|
||||
query = sortByLastModified ? query.OrderByDescending(l => l.LastModified) : query.OrderBy(l => l.Title);
|
||||
|
||||
var finalQuery = query.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
|
||||
.AsNoTracking();
|
||||
@ -383,11 +383,13 @@ public class ReadingListRepository : IReadingListRepository
|
||||
{
|
||||
ReadingListItem = rli,
|
||||
Chapter = chapter,
|
||||
FileSize = _context.MangaFile.Where(f => f.ChapterId == chapter.Id).Sum(f => (long?)f.Bytes) ?? 0
|
||||
})
|
||||
.Join(_context.Volume, x => x.ReadingListItem.VolumeId, volume => volume.Id, (x, volume) => new
|
||||
{
|
||||
x.ReadingListItem,
|
||||
x.Chapter,
|
||||
x.FileSize,
|
||||
Volume = volume
|
||||
})
|
||||
.Join(_context.Series, x => x.ReadingListItem.SeriesId, series => series.Id, (x, series) => new
|
||||
@ -395,6 +397,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
x.ReadingListItem,
|
||||
x.Chapter,
|
||||
x.Volume,
|
||||
x.FileSize,
|
||||
Series = series
|
||||
})
|
||||
.Where(x => userLibraries.Contains(x.Series.LibraryId))
|
||||
@ -407,6 +410,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
x.Chapter,
|
||||
x.Volume,
|
||||
x.Series,
|
||||
x.FileSize,
|
||||
ProgressGroup = progressGroup
|
||||
})
|
||||
.SelectMany(
|
||||
@ -417,6 +421,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
x.Chapter,
|
||||
x.Volume,
|
||||
x.Series,
|
||||
x.FileSize,
|
||||
Progress = progress,
|
||||
PagesRead = progress != null ? progress.PagesRead : 0,
|
||||
HasProgress = progress != null,
|
||||
@ -447,6 +452,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
Order = item.ReadingListItem.Order,
|
||||
SeriesId = item.ReadingListItem.SeriesId,
|
||||
SeriesName = item.Series.Name,
|
||||
SeriesSortName = item.Series.SortName,
|
||||
SeriesFormat = item.Series.Format,
|
||||
PagesTotal = item.Chapter.Pages,
|
||||
PagesRead = item.PagesRead,
|
||||
@ -459,7 +465,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
LibraryType = library.Type,
|
||||
ChapterTitleName = item.Chapter.TitleName,
|
||||
LibraryName = library.Name,
|
||||
FileSize = item.Chapter.Files.Sum(f => f.Bytes), // TODO: See if we can put FileSize on the chapter in future
|
||||
FileSize = item.FileSize,
|
||||
Summary = item.Chapter.Summary,
|
||||
IsSpecial = item.Chapter.IsSpecial,
|
||||
LastReadingProgressUtc = item.Progress?.LastModifiedUtc
|
||||
@ -513,6 +519,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
(data, s) => new
|
||||
{
|
||||
SeriesName = s.Name,
|
||||
SortName = s.SortName,
|
||||
SeriesFormat = s.Format,
|
||||
s.LibraryId,
|
||||
data.ReadingListItem,
|
||||
@ -541,6 +548,7 @@ public class ReadingListRepository : IReadingListRepository
|
||||
Order = x.Data.ReadingListItem.Order,
|
||||
SeriesId = x.Data.ReadingListItem.SeriesId,
|
||||
SeriesName = x.Data.SeriesName,
|
||||
SeriesSortName = x.Data.SortName,
|
||||
SeriesFormat = x.Data.SeriesFormat,
|
||||
PagesTotal = x.Data.TotalPages,
|
||||
ChapterNumber = x.Data.ChapterNumber,
|
||||
|
||||
@ -171,6 +171,13 @@ public class AppUserPreferences
|
||||
/// UI Site Global Setting: Should Kavita render ColorScape gradients
|
||||
/// </summary>
|
||||
public bool ColorScapeEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Enable data saver mode across Kavita, limiting information that is pre-fetched
|
||||
/// </summary>
|
||||
/// <remarks>Currenty only integrated into the PDF reader</remarks>
|
||||
public bool DataSaver { get; set; } = false;
|
||||
|
||||
#endregion
|
||||
|
||||
#region KavitaPlus
|
||||
|
||||
@ -10,41 +10,40 @@ using Microsoft.Extensions.Logging;
|
||||
namespace API.Helpers;
|
||||
#nullable enable
|
||||
|
||||
/**
|
||||
* Contributed by https://github.com/microtherion
|
||||
*
|
||||
* All references to the "PDF Spec" (section numbers, etc.) refer to the
|
||||
* PDF 1.7 Specification a.k.a. PDF32000-1:2008
|
||||
* https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reference for PDF Metadata Format
|
||||
%PDF-1.4 ← Header
|
||||
// Contributed by https://github.com/microtherion
|
||||
//
|
||||
// All references to the "PDF Spec" (section numbers, etc.) refer to the
|
||||
// PDF 1.7 Specification a.k.a. PDF32000-1:2008
|
||||
// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf
|
||||
|
||||
Object 1 0 obj ← Objects containing content
|
||||
<< /Type /Catalog ... >>
|
||||
endobj
|
||||
|
||||
Object 2 0 obj
|
||||
<< /Type /Info ... >>
|
||||
endobj
|
||||
|
||||
...more objects...
|
||||
|
||||
xref ← Cross-reference table
|
||||
0 6
|
||||
0000000000 65535 f
|
||||
0000000015 00000 n ← Object 1 is at byte offset 15
|
||||
0000000109 00000 n ← Object 2 is at byte offset 109
|
||||
...
|
||||
|
||||
trailer ← Trailer dictionary
|
||||
<< /Size 6 /Root 1 0 R /Info 2 0 R >>
|
||||
startxref
|
||||
1234 ← Byte offset where xref starts
|
||||
%%EOF
|
||||
*/
|
||||
// Reference for PDF Metadata Format
|
||||
// <![CDATA[
|
||||
// %PDF-1.4 ← Header
|
||||
//
|
||||
// Object 1 0 obj ← Objects containing content
|
||||
// << /Type /Catalog ... >>
|
||||
// endobj
|
||||
//
|
||||
// Object 2 0 obj
|
||||
// << /Type /Info ... >>
|
||||
// endobj
|
||||
//
|
||||
// ...more objects...
|
||||
//
|
||||
// xref ← Cross-reference table
|
||||
// 0 6
|
||||
// 0000000000 65535 f
|
||||
// 0000000015 00000 n ← Object 1 is at byte offset 15
|
||||
// 0000000109 00000 n ← Object 2 is at byte offset 109
|
||||
// ...
|
||||
//
|
||||
// trailer ← Trailer dictionary
|
||||
// << /Size 6 /Root 1 0 R /Info 2 0 R >>
|
||||
// startxref
|
||||
// 1234 ← Byte offset where xref starts
|
||||
// %%EOF
|
||||
// ]]>
|
||||
|
||||
/// <summary>
|
||||
/// Parse PDF file and try to extract as much metadata as possible.
|
||||
@ -1591,6 +1590,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor
|
||||
case PdfLexer.TokenType.Name:
|
||||
case PdfLexer.TokenType.String:
|
||||
case PdfLexer.TokenType.ObjectRef:
|
||||
case PdfLexer.TokenType.Keyword:
|
||||
break;
|
||||
case PdfLexer.TokenType.ArrayStart:
|
||||
{
|
||||
@ -1602,8 +1602,17 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor
|
||||
SkipDictionary();
|
||||
break;
|
||||
}
|
||||
case PdfLexer.TokenType.StreamStart:
|
||||
{
|
||||
// If we encounter a stream, we need to skip it properly
|
||||
// This is tricky because we need the Length from the dictionary
|
||||
// For now, throw a more informative exception
|
||||
throw new PdfMetadataExtractorException(
|
||||
"Encountered stream object in unexpected context - PDF may have inline streams in dictionary");
|
||||
}
|
||||
default:
|
||||
throw new PdfMetadataExtractorException("Unexpected token in SkipValue");
|
||||
throw new PdfMetadataExtractorException(
|
||||
$"Unexpected token type in SkipValue: {token.Type} with value: {token.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -244,7 +244,7 @@ public class ReadingListService : IReadingListService
|
||||
// Collect all Ids to remove
|
||||
var itemIdsToRemove = items.Where(item => item.PagesRead == item.PagesTotal).Select(item => item.Id).ToList();
|
||||
|
||||
if (!itemIdsToRemove.Any()) return true;
|
||||
if (itemIdsToRemove.Count == 0) return true;
|
||||
try
|
||||
{
|
||||
var listItems =
|
||||
@ -360,8 +360,7 @@ public class ReadingListService : IReadingListService
|
||||
private async Task CalculateReadingListAgeRating(ReadingList readingList, IEnumerable<int> seriesIds)
|
||||
{
|
||||
var ageRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
|
||||
if (ageRating == null) readingList.AgeRating = AgeRating.Unknown;
|
||||
else readingList.AgeRating = (AgeRating) ageRating;
|
||||
readingList.AgeRating = ageRating;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using API.Data.Metadata;
|
||||
using System.IO;
|
||||
using API.Data.Metadata;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.Services.Tasks.Scanner.Parser;
|
||||
@ -7,8 +8,26 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
{
|
||||
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null)
|
||||
{
|
||||
var info = bookService.ParseInfo(filePath);
|
||||
if (info == null) return null;
|
||||
ParserInfo info;
|
||||
if (enableMetadata)
|
||||
{
|
||||
info = bookService.ParseInfo(filePath);
|
||||
if (info == null) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
info = new ParserInfo
|
||||
{
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = MangaFormat.Epub,
|
||||
Title = Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Parser.NormalizePath(filePath),
|
||||
Series = Parser.ParseSeries(fileName, type),
|
||||
Chapters = Parser.ParseChapter(fileName, type),
|
||||
Volumes = Parser.ParseVolume(fileName, type),
|
||||
};
|
||||
}
|
||||
|
||||
info.ComicInfo = comicInfo;
|
||||
|
||||
|
||||
@ -27,9 +27,16 @@ Run `npx playwright test --reporter=line` or `npx playwright test` to run e2e te
|
||||
|
||||
## Connecting to your dev server via your phone or any other compatible client on local network
|
||||
|
||||
Update `IP` constant in `src/environments/environment.ts` to your dev machine's ip instead of `localhost`.
|
||||
Run `npm run start-proxy`
|
||||
|
||||
Run `npm run start`
|
||||
## Testing OIDC
|
||||
|
||||
There's two options,
|
||||
|
||||
1) Run the proxy and correct the port after redirect (on login).
|
||||
2) Run `build-backend` or `build-backend-prod`, and use `localhost:5000` to test. This requires you to rebuild after each change
|
||||
|
||||
Do **NOT** commit appsettings.development.json while testing OIDC. It'll contain your secret key
|
||||
|
||||
## Notes:
|
||||
- injected services should be at the top of the file
|
||||
|
||||
@ -13,8 +13,11 @@
|
||||
}
|
||||
|
||||
.tag-card:hover {
|
||||
background-color: #3a3a3a;
|
||||
background-color: var(--card-hover-bg-color);
|
||||
//transform: translateY(-3px); // Cool effect but has a weird background issue. ROBBIE: Fix this
|
||||
& .tag-name, & .tag-meta {
|
||||
color: var(--card-hover-text-color)
|
||||
}
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
|
||||
@ -15,6 +15,7 @@ export interface Preferences {
|
||||
locale: string;
|
||||
bookReaderHighlightSlots: HighlightSlot[];
|
||||
colorScapeEnabled: boolean;
|
||||
dataSaver: boolean;
|
||||
|
||||
// Kavita+
|
||||
aniListScrobblingEnabled: boolean;
|
||||
|
||||
@ -9,6 +9,7 @@ export interface ReadingListItem {
|
||||
pagesRead: number;
|
||||
pagesTotal: number;
|
||||
seriesName: string;
|
||||
seriesSortName: string;
|
||||
seriesFormat: MangaFormat;
|
||||
seriesId: number;
|
||||
chapterId: number;
|
||||
|
||||
@ -38,7 +38,7 @@ export class FontService {
|
||||
|
||||
getFontFace(font: EpubFont): FontFace {
|
||||
if (font.provider === FontProvider.System) {
|
||||
return new FontFace(font.name, `url('/assets/fonts/${font.name}/${font.fileName}')`);
|
||||
return new FontFace(font.name, `url('assets/fonts/${font.name}/${font.fileName}')`);
|
||||
}
|
||||
|
||||
return new FontFace(font.name, `url(${this.baseUrl}font?fontId=${font.id}&apiKey=${this.encodedKey})`);
|
||||
|
||||
@ -28,12 +28,12 @@
|
||||
color: var(--dropdown-item-text-color);
|
||||
background-color: var(--dropdown-item-bg-color);
|
||||
&:hover {
|
||||
color: var(--dropdown-item-text-color);
|
||||
color: var(--dropdown-item-hover-text-color);
|
||||
background-color: var(--dropdown-item-hover-bg-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
&:focus-visible {
|
||||
color: var(--dropdown-item-text-color);
|
||||
color: var(--dropdown-item-hover-text-color);
|
||||
background-color: var(--dropdown-item-hover-bg-color);
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,6 +58,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--card-overlay-text-color);
|
||||
}
|
||||
|
||||
.overlay-information--centered {
|
||||
position: absolute;
|
||||
border-radius: 15px;
|
||||
|
||||
@ -94,6 +94,7 @@
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-setting-multi-check-box
|
||||
id="libraries"
|
||||
[title]="t('libraries-label')"
|
||||
[options]="libraryOptions()"
|
||||
formControlName="libraries"
|
||||
@ -102,6 +103,7 @@
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-setting-multi-check-box
|
||||
id="roles"
|
||||
[title]="t('roles-label')"
|
||||
[options]="roleOptions"
|
||||
formControlName="roles"
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-setting-multi-check-box
|
||||
id="libraries"
|
||||
[title]="t('libraries-label')"
|
||||
[options]="libraryOptions()"
|
||||
formControlName="libraries"
|
||||
@ -39,6 +40,7 @@
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-setting-multi-check-box
|
||||
id="roles"
|
||||
[title]="t('roles-label')"
|
||||
[options]="roleOptions"
|
||||
formControlName="roles"
|
||||
|
||||
@ -205,19 +205,21 @@
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-setting-multi-check-box
|
||||
[title]="t('default-roles-label')"
|
||||
[options]="roleOptions"
|
||||
id="libraries"
|
||||
[title]="t('default-libraries-label')"
|
||||
[options]="libraryOptions()"
|
||||
[loading]="loading()"
|
||||
formControlName="defaultRoles"
|
||||
formControlName="defaultLibraries"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-setting-multi-check-box
|
||||
[title]="t('default-libraries-label')"
|
||||
[options]="libraryOptions()"
|
||||
id="roles"
|
||||
[title]="t('default-roles-label')"
|
||||
[options]="roleOptions"
|
||||
[loading]="loading()"
|
||||
formControlName="defaultLibraries"
|
||||
formControlName="defaultRoles"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -19,26 +19,26 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (clickToPaginate() && !hidePagination()) {
|
||||
<div class="tap-to-paginate">
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe"
|
||||
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
[ngClass]="{'immersive' : immersiveMode()}"
|
||||
tabindex="-1"></div>
|
||||
<div class="{{scrollbarNeeded() ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
|
||||
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
||||
[ngClass]="{'immersive' : immersiveMode()}"
|
||||
tabindex="-1"></div>
|
||||
</div>
|
||||
}
|
||||
<div #readingSection class="reading-section {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
|
||||
[ngStyle]="{'width': pageWidthForPagination()}"
|
||||
[ngClass]="{'immersive' : immersiveMode() || !actionBarVisible()}" [@isLoading]="isLoading()" (click)="handleReaderClick($event)">
|
||||
|
||||
@if (clickToPaginate() && !hidePagination()) {
|
||||
<div class="left {{clickOverlayClass('left')}} no-observe"
|
||||
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD)"
|
||||
[ngClass]="{'immersive' : immersiveMode()}"
|
||||
tabindex="-1"
|
||||
[ngStyle]="{height: PageHeightForPagination}"></div>
|
||||
<div class="{{scrollbarNeeded() ? 'right-with-scrollbar' : 'right'}} {{clickOverlayClass('right')}} no-observe"
|
||||
(click)="movePage(isLeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS)"
|
||||
[ngClass]="{'immersive' : immersiveMode()}"
|
||||
tabindex="-1"
|
||||
[ngStyle]="{height: PageHeightForPagination}"></div>
|
||||
}
|
||||
|
||||
<div #bookContainer class="book-container {{writingStyle() | writingStyleClass}}"
|
||||
[ngClass]="{'immersive' : immersiveMode()}"
|
||||
(mousedown)="mouseDown($event)" >
|
||||
[ngClass]="{'immersive' : immersiveMode()}"
|
||||
(mousedown)="mouseDown($event)" >
|
||||
|
||||
<div #readingHtml class="book-content {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
|
||||
[ngStyle]="{'max-height': columnHeight(), 'max-width': verticalBookContentWidth(), 'width': verticalBookContentWidth(), 'column-width': columnWidth()}"
|
||||
@ -63,7 +63,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center d-none d-md-block center-group">
|
||||
<div class="text-center center-group">
|
||||
@if (isLoading()) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" style="border-radius: 50%;" role="status">
|
||||
<span class="visually-hidden">{{ t('loading-book') }}</span>
|
||||
|
||||
@ -84,6 +84,7 @@ $action-bar-height: 38px;
|
||||
}
|
||||
|
||||
.center-group {
|
||||
display: block;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
@ -98,6 +99,10 @@ $action-bar-height: 38px;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.center-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.right-group {
|
||||
justify-self: end;
|
||||
}
|
||||
@ -234,6 +239,7 @@ $action-bar-height: 38px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
writing-mode: horizontal-tb;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
|
||||
@ -306,14 +312,21 @@ $pagination-opacity: 0;
|
||||
//$pagination-opacity: 0.7;
|
||||
|
||||
|
||||
|
||||
.tap-to-paginate {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.right {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: $action-bar-height;
|
||||
width: 20vw;
|
||||
z-index: 3;
|
||||
height: calc(100vh - $action-bar-height*2);
|
||||
background: $pagination-color;
|
||||
border-color: transparent;
|
||||
border: none !important;
|
||||
@ -323,6 +336,7 @@ $pagination-opacity: 0;
|
||||
|
||||
&.immersive {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.no-pointer-events {
|
||||
@ -335,8 +349,8 @@ $pagination-opacity: 0;
|
||||
position: absolute;
|
||||
right: 17px;
|
||||
top: $action-bar-height;
|
||||
width: 18%;
|
||||
z-index: 3;
|
||||
width: 18vw;
|
||||
height: calc(100vh - $action-bar-height*2);
|
||||
background: $pagination-color;
|
||||
opacity: $pagination-opacity;
|
||||
border-color: transparent;
|
||||
@ -346,6 +360,7 @@ $pagination-opacity: 0;
|
||||
|
||||
&.immersive {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@ -354,17 +369,17 @@ $pagination-opacity: 0;
|
||||
left: 0px;
|
||||
top: $action-bar-height;
|
||||
width: 20vw;
|
||||
height: calc(100vh - $action-bar-height*2);
|
||||
background: $pagination-color;
|
||||
opacity: $pagination-opacity;
|
||||
border-color: transparent;
|
||||
border: none !important;
|
||||
z-index: 3;
|
||||
outline: none;
|
||||
height: 100vw;
|
||||
cursor: pointer;
|
||||
|
||||
&.immersive {
|
||||
top: 0px;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@ -410,7 +425,7 @@ $pagination-opacity: 0;
|
||||
|
||||
i {
|
||||
background-color: unset;
|
||||
color: var(--br-actionbar-button-text-color);
|
||||
color: var(--br-actionbar-button-text-color) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
||||
@ -359,6 +359,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
*/
|
||||
debugMode = model<boolean>(!environment.production && true);
|
||||
|
||||
/**
|
||||
* Will be set to true if this.scroll(...) is called but the actual scroll is still delayed
|
||||
* This can also be used to debug glitches or race conditions related to page scrolling
|
||||
* For instance, when we invoke a scroll action, but another scroll is scheduled to be triggered afterward
|
||||
*/
|
||||
hasDelayedScroll: boolean = false;
|
||||
|
||||
|
||||
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
|
||||
@ -506,23 +512,26 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
|
||||
get PageHeightForPagination() {
|
||||
pageHeightForPagination = computed(() => {
|
||||
const layoutMode = this.layoutMode();
|
||||
const immersiveMode = this.immersiveMode();
|
||||
const widthHeight = this.windowHeight();
|
||||
|
||||
if (layoutMode=== BookPageLayoutMode.Default) {
|
||||
if (layoutMode === BookPageLayoutMode.Default) {
|
||||
// Ensure Angular updates this pageHeightForPagination when these signal have an update
|
||||
if (this.isLoading()) return;
|
||||
this.windowHeight();
|
||||
this.writingStyle();
|
||||
|
||||
// if the book content is less than the height of the container, override and return height of container for pagination area
|
||||
if (this.bookContainerElemRef?.nativeElement?.clientHeight > this.bookContentElemRef?.nativeElement?.clientHeight) {
|
||||
return (this.bookContainerElemRef?.nativeElement?.clientHeight || 0) + 'px';
|
||||
}
|
||||
|
||||
return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px';
|
||||
return (this.bookContentElemRef?.nativeElement?.scrollHeight || 0) - ((this.topOffset * (immersiveMode ? 0 : 1)) * 2) + 'px';
|
||||
}
|
||||
|
||||
if (immersiveMode) return widthHeight + 'px';
|
||||
return (widthHeight) - (this.topOffset * 2) + 'px';
|
||||
}
|
||||
return '100%';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.navService.hideNavBar();
|
||||
@ -534,9 +543,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const layoutMode = this.layoutMode();
|
||||
const writingStyle = this.writingStyle();
|
||||
|
||||
const windowWidth = this.windowWidth();
|
||||
const marginLeft = this.pageStyles()['margin-left'];
|
||||
const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2);
|
||||
// const windowWidth = this.windowWidth();
|
||||
// const marginLeft = this.pageStyles()['margin-left'];
|
||||
// const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2);
|
||||
const base = writingStyle === WritingStyle.Vertical ? this.pageHeight() : this.pageWidth();
|
||||
|
||||
|
||||
@ -950,27 +959,33 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.updateWidthAndHeightCalcs();
|
||||
this.updateImageSizes();
|
||||
|
||||
// Refresh page styles to handle margin changes on window resize
|
||||
this.applyPageStyles(this.pageStyles());
|
||||
|
||||
// Attempt to restore the reading position
|
||||
this.snapScrollOnResize();
|
||||
|
||||
afterFrame(() => {
|
||||
this.injectImageBookmarkIndicators(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only applies to non BookPageLayoutMode. Default and WritingStyle Horizontal
|
||||
* Only applies to non BookPageLayoutMode.Default and WritingStyle Horizontal
|
||||
* @private
|
||||
*/
|
||||
private snapScrollOnResize() {
|
||||
const layoutMode = this.layoutMode();
|
||||
if (layoutMode === BookPageLayoutMode.Default) return;
|
||||
|
||||
const resumeElement = this.lastSeenScrollPartPath || (this.getFirstVisibleElementXPath() ?? '');
|
||||
if (resumeElement) {
|
||||
|
||||
const resumeElement = this.getFirstVisibleElementXPath() ?? null;
|
||||
if (resumeElement !== null) {
|
||||
|
||||
const element = this.getElementFromXPath(resumeElement);
|
||||
//console.log('Attempting to snap to element: ', element);
|
||||
if (this.debugMode()) {
|
||||
const element = this.getElementFromXPath(resumeElement);
|
||||
//console.log('Attempting to snap to element: ', element);
|
||||
this.logSelectedElement('yellow');
|
||||
}
|
||||
|
||||
this.scrollTo(resumeElement, 30); // This works pretty well, but not perfect
|
||||
}
|
||||
@ -1360,7 +1375,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
this.document.documentElement.style.setProperty('--book-reader-content-max-height', maxHeight);
|
||||
this.document.documentElement.style.setProperty('--book-reader-content-max-width', maxWidth);
|
||||
|
||||
}
|
||||
|
||||
updateSingleImagePageStyles() {
|
||||
@ -1401,7 +1415,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// Virtual Paging stuff
|
||||
this.updateWidthAndHeightCalcs();
|
||||
this.applyLayoutMode(this.layoutMode());
|
||||
this.addEmptyPageIfRequired();
|
||||
// this.addEmptyPageIfRequired(); // Already called in this.applyPageStyles()
|
||||
|
||||
// Find all the part ids and their top offset
|
||||
this.setupPageAnchors();
|
||||
@ -1415,7 +1429,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// we need to click the document before arrow keys will scroll down.
|
||||
this.reader.nativeElement.focus();
|
||||
this.scroll(() => this.handleScrollEvent()); // Will set lastSeenXPath and save progress
|
||||
afterFrame(() => this.handleScrollEvent()); // Will set lastSeenXPath and save progress
|
||||
this.isLoading.set(false);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
@ -1425,8 +1439,15 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private scroll(lambda: () => void) {
|
||||
if (this.hasDelayedScroll) console.warn("Another scroll operation is still pending while this scroll function is being called again");
|
||||
this.hasDelayedScroll = true;
|
||||
|
||||
// `afterFrame() + setTimeout()` can likely be replaced with `requestAnimationFrame()` instead
|
||||
afterFrame(() => {
|
||||
setTimeout(lambda, SCROLL_DELAY)
|
||||
setTimeout(() => {
|
||||
this.hasDelayedScroll = false;
|
||||
lambda();
|
||||
}, SCROLL_DELAY)
|
||||
});
|
||||
}
|
||||
|
||||
@ -1485,26 +1506,34 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private addEmptyPageIfRequired(): void {
|
||||
const bookContentElem = this.bookContentElemRef.nativeElement;
|
||||
const oldEmptyGap = bookContentElem.querySelector('.kavita-empty-gap');
|
||||
|
||||
if (this.layoutMode() !== BookPageLayoutMode.Column2 || this.isSingleImagePage) {
|
||||
oldEmptyGap?.remove(); // We don't need empty gap for this condition
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = this.pageSize();
|
||||
const [_, totalScroll] = this.getScrollOffsetAndTotalScroll();
|
||||
let [_, totalScroll] = this.getScrollOffsetAndTotalScroll();
|
||||
|
||||
if (oldEmptyGap) totalScroll -= pageSize/2;
|
||||
const lastPageSize = totalScroll % pageSize;
|
||||
|
||||
if (lastPageSize >= pageSize / 2 || lastPageSize === 0) {
|
||||
// The last page needs more than one column, no pages will be duplicated
|
||||
oldEmptyGap?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to adjust height with the column gap to ensure we don't have too much extra page
|
||||
const columnHeight = this.pageHeight() - COLUMN_GAP;
|
||||
const emptyPage = this.renderer.createElement('div');
|
||||
const columnHeight = this.pageHeight() - (COLUMN_GAP * 2);
|
||||
const emptyPage = oldEmptyGap ?? this.renderer.createElement('div');
|
||||
emptyPage.classList.add('kavita-empty-gap');
|
||||
|
||||
this.renderer.setStyle(emptyPage, 'height', columnHeight + 'px');
|
||||
this.renderer.setStyle(emptyPage, 'width', this.columnWidth());
|
||||
this.renderer.appendChild(this.bookContentElemRef.nativeElement, emptyPage);
|
||||
this.renderer.appendChild(bookContentElem, emptyPage);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
@ -1548,7 +1577,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
if (currentVirtualPage > 1) {
|
||||
// Calculate the target scroll position for the previous page
|
||||
const targetScroll = (currentVirtualPage - 2) * pageSize - (this.layoutMode() === BookPageLayoutMode.Column2 ? 3 : 0)
|
||||
const targetScroll = (currentVirtualPage - 2) * pageSize;
|
||||
|
||||
const isVertical = this.writingStyle() === WritingStyle.Vertical;
|
||||
|
||||
@ -1601,7 +1630,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (currentVirtualPage < totalVirtualPages) {
|
||||
|
||||
// Calculate the target scroll position for the next page
|
||||
const targetScroll = (currentVirtualPage * pageSize) + (this.layoutMode() === BookPageLayoutMode.Column2 ? 1 : 0);
|
||||
const targetScroll = (currentVirtualPage * pageSize);
|
||||
const isVertical = this.writingStyle() === WritingStyle.Vertical;
|
||||
|
||||
// +0 apparently goes forward 1 virtual page...
|
||||
@ -1649,9 +1678,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const columnGapModifier = this.columnGapModifier();
|
||||
if (this.readingSectionElemRef == null) return 0;
|
||||
|
||||
// Give an additional pixels for buffer
|
||||
return this.readingSectionElemRef.nativeElement.clientWidth - margin
|
||||
+ (COLUMN_GAP * columnGapModifier);
|
||||
return this.reader.nativeElement.offsetWidth - margin + (COLUMN_GAP * columnGapModifier);
|
||||
});
|
||||
|
||||
columnGapModifier = computed(() => {
|
||||
@ -1674,13 +1701,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
|
||||
|
||||
getVerticalPageWidth() {
|
||||
getVerticalPageWidth = computed(() => {
|
||||
if (!(this.pageStyles() || {}).hasOwnProperty('margin-left')) return 0; // TODO: Test this, added for safety during refactor
|
||||
|
||||
const margin = (window.innerWidth * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2;
|
||||
const windowWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const margin = (this.windowWidth() * (parseInt(this.pageStyles()['margin-left'], 10) / 100)) * 2;
|
||||
const windowWidth = this.windowWidth() || document.documentElement.clientWidth;
|
||||
return windowWidth - margin;
|
||||
}
|
||||
});
|
||||
|
||||
convertVwToPx(vwValue: number) {
|
||||
const viewportWidth = Math.max(this.readingSectionElemRef?.nativeElement?.clientWidth ?? 0, window.innerWidth || 0);
|
||||
@ -1741,21 +1768,40 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
getFirstVisibleElementXPath() {
|
||||
let resumeElement: string | null = null;
|
||||
if (!this.bookContentElemRef || !this.bookContentElemRef.nativeElement) return null;
|
||||
const bookContentElement = this.bookContentElemRef?.nativeElement;
|
||||
if (!bookContentElement) return null;
|
||||
|
||||
//const container = this.getViewportBoundingRect();
|
||||
|
||||
const intersectingEntries = Array.from(this.bookContentElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span'))
|
||||
const intersectingEntries = Array.from(bookContentElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span,figure'))
|
||||
.filter(element => !element.classList.contains('no-observe'))
|
||||
.filter(entry => {
|
||||
//return this.isPartiallyContainedIn(container, entry);
|
||||
return this.utilityService.isInViewport(entry, this.topOffset);
|
||||
.filter(element => {
|
||||
//return this.isPartiallyContainedIn(container, element);
|
||||
return this.utilityService.isInViewport(element, this.topOffset)
|
||||
|
||||
/* Remove main container element
|
||||
<div class="book-content"> <-- bookContentElement
|
||||
<div class="body"> <--- we don't need this
|
||||
<style></style>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
*/
|
||||
&& element.parentElement !== bookContentElement;
|
||||
})
|
||||
.filter((element, i, entries) => {
|
||||
// Remove any children element contained in another element that exist on this entries
|
||||
return !entries.some(item => element !== item && item.contains(element))
|
||||
|
||||
// Remove element that don't have any content
|
||||
&& (element.textContent?.trim().length || element.querySelectorAll('img, svg').length !== 0 || /^(img|svg)$/im.test(element.tagName));
|
||||
});
|
||||
|
||||
intersectingEntries.sort((a, b) => this.sortElementsForLayout(a, b));
|
||||
|
||||
if (intersectingEntries.length > 0) {
|
||||
let path = this.readerService.getXPathTo(intersectingEntries[0]);
|
||||
const element = this.findTopLevelElement(intersectingEntries[0], intersectingEntries[1], bookContentElement);
|
||||
let path = this.readerService.getXPathTo(element);
|
||||
if (path === '') return;
|
||||
|
||||
resumeElement = path;
|
||||
@ -1763,6 +1809,63 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return resumeElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the top level element that has the same parent.
|
||||
* Illustrated with example below:
|
||||
*
|
||||
* <section>
|
||||
* <p> <-- We want to get this element instead
|
||||
* <span> ... target ... </span>
|
||||
* </p>
|
||||
* <p>
|
||||
* <span> ... nextSibling ... </span>
|
||||
* </p>
|
||||
* <section>
|
||||
*/
|
||||
private findTopLevelElement(target: Element, nextSibling: Element, root: Element): Element | null {
|
||||
|
||||
// If no sibling provided, then lets transverse to parent element where the element display is not inline
|
||||
if (nextSibling == null) {
|
||||
let current: Element | null = target;
|
||||
while (current && current !== root) {
|
||||
const displayStyle = window.getComputedStyle(current).getPropertyValue('display');
|
||||
|
||||
if (!displayStyle.includes('inline')) return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
// Immediately return if it's already sibling
|
||||
if (target.parentElement === nextSibling.parentElement) return target;
|
||||
|
||||
const ancestors: Element[] = [];
|
||||
let current: Element | null = null
|
||||
|
||||
// Collect all parent element from the next sibling
|
||||
current = nextSibling.parentElement;
|
||||
while (current && current !== root) {
|
||||
ancestors.push(current);
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
// Traverse up from target to find the similar parent with nextSibling
|
||||
current = target;
|
||||
while (current && current !== root) {
|
||||
let parent: Element | null = current.parentElement;
|
||||
|
||||
if (parent && ancestors.includes(parent)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
console.warn("Unable to find similar parent element from the next sibling", target, nextSibling);
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort elements based on layout mode for better scroll position tracking
|
||||
*/
|
||||
@ -1881,7 +1984,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.updateImageSizes(); // Re-call this as we will change window width/height again
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollTo(resumeElement);
|
||||
this.addEmptyPageIfRequired(); // Try add after layout updated on next frame
|
||||
|
||||
// When the user switches pages, there may be a pending scroll that moves to the start or end of the page
|
||||
// For example, `this.scrollWithinPage(...)` might be triggered when the user presses the prev/next page button
|
||||
// So, we don't need to do another page scroll here
|
||||
if (!this.hasDelayedScroll) {
|
||||
this.scrollTo(resumeElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1961,7 +2071,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
scrollTo(partSelector: string, timeout: number = 0) {
|
||||
const element = this.getElementFromXPath(partSelector);
|
||||
const element = this.getElementFromXPath(partSelector) as HTMLElement;
|
||||
|
||||
if (element === null) {
|
||||
if (!environment.production) {
|
||||
@ -1975,7 +2085,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const writingStyle = this.writingStyle();
|
||||
|
||||
if (layout !== BookPageLayoutMode.Default) {
|
||||
afterFrame(() => this.scrollService.scrollIntoView(element as HTMLElement, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}}));
|
||||
afterFrame(() => {
|
||||
// scrollIntoView method will only scroll to the visible area of the element (not including margin)
|
||||
// so we need to apply scroll-margin to that element to correctly scroll into it
|
||||
let margin = window.getComputedStyle(element).margin;
|
||||
if(margin !== '0px') element.style.scrollMargin = margin;
|
||||
|
||||
this.scrollService.scrollIntoView(element, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2063,6 +2180,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.updateImageSizeTimeout = setTimeout( () => {
|
||||
this.updateImageSizes();
|
||||
this.injectImageBookmarkIndicators(true);
|
||||
|
||||
// This needs to be checked after the bookmark indicator has been injected or removed
|
||||
// When switching layout, these indicators may affect the page's total scrollWidth
|
||||
this.addEmptyPageIfRequired();
|
||||
}, 200);
|
||||
|
||||
this.updateSingleImagePageStyles();
|
||||
@ -2332,7 +2453,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
redRect.style.top = `${viewport.top}px`;
|
||||
redRect.style.width = `${viewport.width}px`;
|
||||
redRect.style.height = `${viewport.height}px`;
|
||||
redRect.style.border = '1px solid red';
|
||||
redRect.style.outline = '1px solid red';
|
||||
redRect.style.pointerEvents = 'none';
|
||||
redRect.style.zIndex = '1000';
|
||||
redRect.title = `Width: ${viewport.width}px`;
|
||||
@ -2357,7 +2478,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
greenRect.style.top = `${viewport.top}px`;
|
||||
greenRect.style.width = `${margin}px`;
|
||||
greenRect.style.height = `${viewport.height}px`;
|
||||
greenRect.style.border = '1px solid green';
|
||||
greenRect.style.outline = '1px solid green';
|
||||
greenRect.style.pointerEvents = 'none';
|
||||
greenRect.style.zIndex = '1000';
|
||||
greenRect.title = `Width: ${margin}px`;
|
||||
@ -2376,7 +2497,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
greenRect.style.top = `${viewport.top}px`;
|
||||
greenRect.style.width = `${margin}px`;
|
||||
greenRect.style.height = `${viewport.height}px`;
|
||||
greenRect.style.border = '1px solid green';
|
||||
greenRect.style.outline = '1px solid green';
|
||||
greenRect.style.pointerEvents = 'none';
|
||||
greenRect.style.zIndex = '1000';
|
||||
greenRect.title = `Width: ${margin}px`;
|
||||
@ -2437,13 +2558,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
|
||||
logSelectedElement() {
|
||||
const element = this.getElementFromXPath(this.lastSeenScrollPartPath);
|
||||
logSelectedElement(color='red') {
|
||||
const element = this.getElementFromXPath(this.lastSeenScrollPartPath) as HTMLElement | null;
|
||||
if (element) {
|
||||
console.log(element);
|
||||
(element as HTMLElement).style.border = '1px solid red';
|
||||
element.style.outline = '1px solid ' + color;
|
||||
setTimeout(() => {
|
||||
(element as HTMLElement).style.border = '';
|
||||
element.style.outline = '';
|
||||
}, 1_000);
|
||||
}
|
||||
}
|
||||
@ -2451,8 +2572,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly environment = environment;
|
||||
protected readonly BookPageLayoutMode = BookPageLayoutMode;
|
||||
protected readonly WritingStyle = WritingStyle;
|
||||
protected readonly ReadingDirection = ReadingDirection;
|
||||
protected readonly PAGING_DIRECTION = PAGING_DIRECTION;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
.clickable:hover, .clickable:focus {
|
||||
background-color: var(--list-group-hover-bg-color, --primary-color);
|
||||
background-color: var(--list-group-hover-bg-color, var(--primary-color));
|
||||
}
|
||||
|
||||
.collection {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
.clickable:hover, .clickable:focus {
|
||||
background-color: var(--list-group-hover-bg-color, --primary-color);
|
||||
background-color: var(--list-group-hover-bg-color, var(--primary-color));
|
||||
}
|
||||
|
||||
.pill {
|
||||
|
||||
@ -267,6 +267,8 @@ export class CardDetailLayoutComponent<TFilter extends number, TSort extends num
|
||||
let name = '';
|
||||
if (item.hasOwnProperty('sortName')) {
|
||||
name = item.sortName;
|
||||
} else if (item.hasOwnProperty('seriesSortName')) { // Reading List Item
|
||||
name = item.seriesSortName;
|
||||
} else if (item.hasOwnProperty('seriesName')) {
|
||||
name = item.seriesName;
|
||||
} else if (item.hasOwnProperty('name')) {
|
||||
|
||||
@ -59,10 +59,18 @@ export class CarouselReelComponent {
|
||||
|
||||
swiper: Swiper | undefined;
|
||||
|
||||
get progressChange() {
|
||||
const totalItems = this.items.length;
|
||||
const itemsToMove = Math.min(5, totalItems);
|
||||
const progressPerItem = 1 / totalItems;
|
||||
return Math.min(0.25, progressPerItem * itemsToMove);
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
if (this.swiper) {
|
||||
if (this.swiper.isEnd) return;
|
||||
this.swiper.setProgress(this.swiper.progress + 0.25, 600);
|
||||
|
||||
this.swiper.setProgress(this.swiper.progress + this.progressChange, 600);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
@ -70,7 +78,7 @@ export class CarouselReelComponent {
|
||||
prevPage() {
|
||||
if (this.swiper) {
|
||||
if (this.swiper.isBeginning) return;
|
||||
this.swiper.setProgress(this.swiper.progress - 0.25, 600);
|
||||
this.swiper.setProgress(this.swiper.progress - this.progressChange, 600);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
.text-accent {
|
||||
font-size: small;
|
||||
color: var(---accent-text-color);
|
||||
color: var(--accent-text-color);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
@if (accountService.currentUser$ | async; as user) {
|
||||
<div class="{{theme}}" #container>
|
||||
|
||||
@if (isLoading) {
|
||||
@if (isLoading && !disableLoadingIndicator()) {
|
||||
<div class="loading-message-container">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
{{t('loading-message')}}
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
OnInit, signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
@ -110,6 +110,10 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
backgroundColor: string = this.themeMap[this.theme].background;
|
||||
fontColor: string = this.themeMap[this.theme].font;
|
||||
|
||||
/**
|
||||
* True if Preferences.DataSaver is true
|
||||
*/
|
||||
disableLoadingIndicator = signal(false);
|
||||
isLoading: boolean = true;
|
||||
/**
|
||||
* How much of the current document is loaded
|
||||
@ -260,6 +264,9 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
this.backgroundColor = this.themeMap[this.theme].background;
|
||||
this.fontColor = this.themeMap[this.theme].font; // TODO: Move this to an observable or something
|
||||
|
||||
this.disableLoadingIndicator.set(this.user.preferences.dataSaver);
|
||||
pdfDefaultOptions.disableAutoFetch = this.user.preferences.dataSaver;
|
||||
|
||||
this.calcScrollbarNeeded();
|
||||
|
||||
this.bookService.getBookInfo(this.chapterId).subscribe(info => {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core';
|
||||
import {Router} from '@angular/router';
|
||||
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs/operators';
|
||||
import {JumpKey} from 'src/app/_models/jumpbar/jump-key';
|
||||
@ -43,7 +42,6 @@ export class ReadingListsComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private jumpbarService = inject(JumpbarService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private ngbModal = inject(NgbModal);
|
||||
private titleService = inject(Title);
|
||||
|
||||
protected readonly WikiLink = WikiLink;
|
||||
|
||||
@ -24,11 +24,11 @@
|
||||
@for (opt of options(); track opt.value; let index = $index) {
|
||||
<li class="list-group-item">
|
||||
<div class="form-check">
|
||||
<input id="option--{{index}}" type="checkbox" class="form-check-input"
|
||||
<input id="{{id()}}--option--{{index}}" type="checkbox" class="form-check-input"
|
||||
[checked]="isChecked(opt)" (change)="onCheckboxChange(opt, $event)"
|
||||
[disabled]="isDisabled(opt)"
|
||||
>
|
||||
<label class="form-check-label" for="option--{{index}}">{{opt.label}}</label>
|
||||
<label class="form-check-label" for="{{id()}}--option--{{index}}">{{opt.label}}</label>
|
||||
@if (opt.colour) {
|
||||
@let c = opt.colour;
|
||||
<i class="fas fa-circle" [ngStyle]="{ 'color': `rgba(${c.r}, ${c.g}, ${c.b}, ${c.a})` }"></i>
|
||||
|
||||
@ -64,6 +64,10 @@ export interface MultiCheckBoxItem<T> {
|
||||
})
|
||||
export class SettingMultiCheckBox<T> implements ControlValueAccessor {
|
||||
|
||||
/**
|
||||
* Id to prepend to input id to ensure uniqueness
|
||||
*/
|
||||
id = input.required<string>();
|
||||
/**
|
||||
* Title to display above the checkboxes
|
||||
*/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
@use '../../../../theme/variables' as theme;
|
||||
|
||||
h2 {
|
||||
color: white;
|
||||
color: var(--side-nav-header-text-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
.side-nav-header {
|
||||
color: #d5d5d5;
|
||||
color: var(--pref-side-nav-header-text-color);
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
@ -84,6 +84,18 @@
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('data-saver-label')" [subtitle]="t('data-saver-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="data-saver"
|
||||
formControlName="dataSaver" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [canEdit]="false" [showEdit]="false" [allowClickEvents]="true"
|
||||
[title]="t('highlight-bar-label')" [subtitle]="t('highlight-bar-tooltip')">
|
||||
|
||||
@ -47,6 +47,7 @@ type UserPreferencesForm = FormGroup<{
|
||||
locale: FormControl<string>,
|
||||
bookReaderHighlightSlots: FormArray<FormControl<HighlightSlot>>,
|
||||
colorScapeEnabled: FormControl<boolean>,
|
||||
dataSaver: FormControl<boolean>,
|
||||
|
||||
aniListScrobblingEnabled: FormControl<boolean>,
|
||||
wantToReadSync: FormControl<boolean>,
|
||||
@ -96,9 +97,6 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
loading = signal(true);
|
||||
ageRatings = signal<AgeRatingDto[]>([]);
|
||||
libraries = signal<Library[]>([]);
|
||||
libraryOptions = computed(() => this.libraries().map(l => {
|
||||
return { label: l.name, value: l.id };
|
||||
}));
|
||||
|
||||
locales: Array<KavitaLocale> = [];
|
||||
|
||||
@ -165,6 +163,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
locale: this.fb.control<string>(pref.locale || 'en'),
|
||||
bookReaderHighlightSlots: this.fb.array(pref.bookReaderHighlightSlots.map(s => this.fb.control(s))),
|
||||
colorScapeEnabled: this.fb.control<boolean>(pref.colorScapeEnabled),
|
||||
dataSaver: this.fb.control<boolean>(pref.dataSaver),
|
||||
|
||||
aniListScrobblingEnabled: this.fb.control<boolean>(pref.aniListScrobblingEnabled),
|
||||
wantToReadSync: this.fb.control<boolean>(pref.wantToReadSync),
|
||||
|
||||
@ -210,6 +210,8 @@
|
||||
"highlight-bar-tooltip": "These colors are shared between all books",
|
||||
"colorscape-label": "Use ColorScape",
|
||||
"colorscape-tooltip": "Global toggle to enable/disable the dynamic gradient feature. Will override theme settings",
|
||||
"data-saver-label": "Data saver",
|
||||
"data-saver-tooltip": "Minimizes data usage by preventing automatic prefetching (e.g., PDF reader)",
|
||||
|
||||
"kavitaplus-settings-title": "Kavita+",
|
||||
"anilist-scrobbling-label": "AniList Scrobbling",
|
||||
@ -782,7 +784,7 @@
|
||||
|
||||
"license": {
|
||||
"title": "Kavita+ License",
|
||||
"kavita+-warning": "Kavita+ is separate from Kavita. If you uninstall Kavita without unsubscribing, you will be charged.",
|
||||
"kavita+-warning": "Kavita+ is separate from Kavita. Uninstalling without first cancelling your subscription will continue the billing cycle.",
|
||||
"manage": "Manage",
|
||||
"invalid-license-tooltip": "If your subscription has ended, you must email support to get a new subscription created",
|
||||
"check": "Check",
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
const IP = 'localhost';
|
||||
|
||||
// All requests to the backend are proxies through the Angular server, we let the browser pick the host
|
||||
// This comes with the advantage that you don't need to change anything to test on a different device on the
|
||||
// network.
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://' + IP + ':4200/api/',
|
||||
hubUrl: 'http://'+ IP + ':4200/hubs/',
|
||||
apiUrl: '/api/',
|
||||
hubUrl: '/hubs/',
|
||||
buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL',
|
||||
manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss'
|
||||
};
|
||||
|
||||
@ -14,10 +14,18 @@
|
||||
--bs-btn-active-bg: var(--primary-color-dark-shade);
|
||||
--bs-btn-active-border-color: var(--primary-color-dark-shade);
|
||||
|
||||
&:hover {
|
||||
i {
|
||||
color: var(--btn-primary-text-color);
|
||||
}
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
color: var(--btn-primary-hover-text-color);
|
||||
background-color: var(--btn-primary-hover-bg-color);
|
||||
border-color: var(--btn-primary-hover-border-color);
|
||||
|
||||
i {
|
||||
color: var(--btn-primary-hover-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,10 +38,18 @@
|
||||
background-color: var(--btn-outline-primary-bg-color);
|
||||
border-color: var(--btn-outline-primary-border-color);
|
||||
|
||||
&:hover {
|
||||
i {
|
||||
color: var(--btn-outline-primary-text-color);
|
||||
}
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
color: var(--btn-outline-primary-hover-text-color) !important;
|
||||
background-color: var(--btn-outline-primary-hover-bg-color) !important;
|
||||
border-color: var(--btn-outline-primary-hover-border-color) !important;
|
||||
|
||||
i {
|
||||
color: var(--btn-outline-primary-hover-text-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +98,11 @@
|
||||
background-color: var(--btn-secondary-outline-bg-color);
|
||||
border-color: var(--btn-secondary-outline-border-color);
|
||||
|
||||
&:hover {
|
||||
i {
|
||||
color: var(--btn-secondary-outline-text-color);
|
||||
}
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
--bs-btn-color: var(--btn-secondary-outline-hover-text-color);
|
||||
--bs-btn-hover-bg: var(-btn-secondary-outline-hover-bg-color);
|
||||
--bs-btn-hover-border-color: var(--btn-secondary-outline-hover-border-color);
|
||||
@ -94,6 +114,10 @@
|
||||
box-shadow: inset 0px -2px 0px 0px var(--btn-secondary-outline-text-color);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
i {
|
||||
color: var(--btn-secondary-outline-hover-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,10 +127,18 @@
|
||||
background-color: var(--btn-danger-outline-bg-color);
|
||||
border-color: var(--btn-danger-outline-border-color);
|
||||
|
||||
&:hover {
|
||||
i {
|
||||
color: var(--btn-danger-outline-text-color);
|
||||
}
|
||||
|
||||
&:hover, &:focus-visible {
|
||||
color: var(--btn-danger-outline-hover-text-color);
|
||||
background-color: var(--btn-danger-outline-hover-bg-color);
|
||||
border-color: var(--btn-danger-outline-hover-border-color);
|
||||
|
||||
i {
|
||||
color: var(--btn-danger-outline-hover-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,6 +164,9 @@
|
||||
background-color: var(--btn-disabled-bg-color);
|
||||
color: var(--btn-disabled-text-color);
|
||||
border-color: var(--btn-disabled-border-color);
|
||||
i {
|
||||
color: var(--btn-disabled-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :disabled {
|
||||
@ -144,14 +179,14 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
|
||||
color: var(--body-text-color) !important;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
&:hover, &:focus, &:focus-visible {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.btn:focus, .btn:active, .btn:active:focus {
|
||||
box-shadow: 0 0 0 0 var(---btn-focus-boxshadow-color) !important;
|
||||
box-shadow: 0 0 0 0 var(--btn-focus-boxshadow-color) !important;
|
||||
}
|
||||
|
||||
|
||||
@ -160,11 +195,15 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
|
||||
color: var(--body-text-color);
|
||||
border: none;
|
||||
|
||||
i {
|
||||
color: var(--body-text-color);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
--bs-btn-disabled-bg: transparent;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
&:hover, &:focus, &:focus-visible {
|
||||
color: var(--body-text-color);
|
||||
border: none;
|
||||
}
|
||||
@ -178,14 +217,26 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
|
||||
|
||||
.btn-primary-text {
|
||||
color: var(--btn-primary-text-text-color);
|
||||
|
||||
i {
|
||||
color: var(--btn-primary-text-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary-text {
|
||||
color: var(--btn-secondary-text-text-color);
|
||||
|
||||
i {
|
||||
color: var(--btn-secondary-text-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-danger-text {
|
||||
color: var(--btn-danger-text-text-color);
|
||||
|
||||
i {
|
||||
color: var(--btn-danger-text-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -194,6 +245,10 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
|
||||
color: var(--btn-secondary-text-color);
|
||||
background-color: var(--btn-secondary-bg-color);
|
||||
border-color: var(--btn-secondary-border-color);
|
||||
|
||||
i {
|
||||
color: var(--btn-secondary-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn.btn-secondary.alt {
|
||||
@ -205,15 +260,17 @@ button:disabled, .form-control:disabled, .form-control[readonly], .disabled, :di
|
||||
font-weight: var(--btn-secondary-font-weight);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus, &:focus-visible {
|
||||
background-color: var(--btn-alt-focus-bg-color);
|
||||
box-shadow: 0 0 0 0.05rem var(--btn-alt-focus-boxshadow-color);
|
||||
font-weight: var(--btn-secondary-font-weight);
|
||||
}
|
||||
}
|
||||
|
||||
button i.fa {
|
||||
color: var(--btn-fa-icon-color);
|
||||
button {
|
||||
i.fa, i.fa-regular {
|
||||
color: var(--btn-fa-icon-color);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-check:focus + .btn, .btn:focus {
|
||||
@ -230,7 +287,6 @@ button i.fa {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
outline: inherit;
|
||||
}
|
||||
@ -239,10 +295,3 @@ button i.fa {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
//
|
||||
//.btn-primary .btn-check:checked + .btn, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show {
|
||||
// --bs-btn-active-bg: var(--primary-color-dark-shade);
|
||||
// --bs-btn-active-border-color: var(--primary-color-dark-shade);
|
||||
//}
|
||||
|
||||
|
||||
|
||||
@ -38,6 +38,10 @@ $image-height: 232.91px;
|
||||
$image-filter-height: 160px;
|
||||
$image-width: 160px;
|
||||
|
||||
.card-title {
|
||||
--bs-card-title-color: var(--card-title-text-color);
|
||||
}
|
||||
|
||||
.card-item-container {
|
||||
.card {
|
||||
max-width: $image-width;
|
||||
@ -76,12 +80,19 @@ $image-width: 160px;
|
||||
height: $image-filter-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .card-title {
|
||||
color: var(--card-overlay-text-color);
|
||||
}
|
||||
|
||||
& + .card-body {
|
||||
color: var(--card-overlay-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0 5px !important;
|
||||
background-color: rgba(0,0,0,0.7);
|
||||
background-color: var(--card-body-bg-color);
|
||||
border-width: var(--card-border-width);
|
||||
border-style: var(--card-border-style);
|
||||
border-color: var(--card-border-color);
|
||||
|
||||
@ -3,14 +3,16 @@ input:not([type="range"]), .form-control {
|
||||
color: var(--input-text-color);
|
||||
border-color: var(--input-border-color);
|
||||
|
||||
&:focus {
|
||||
&:focus:not(:checked) {
|
||||
border-color: var(--input-focused-border-color);
|
||||
background-color: var(--input-bg-color);
|
||||
color: var(--input-text-color);
|
||||
box-shadow: 0 0 0 .25rem var(--input-focus-boxshadow-color);
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
// Checkboxes are selected by the :read-only pseudo-class, even when they're editable
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/CSS/:read-only
|
||||
&:read-only:not([type="checkbox"]) {
|
||||
background-color: var(--input-bg-readonly-color);
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
@ -14,11 +14,6 @@
|
||||
box-shadow: 0 0 0 0.25rem var(--input-focus-boxshadow-color);
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
background-color: var(--input-bg-readonly-color);
|
||||
cursor: initial;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
||||
@ -114,8 +114,7 @@
|
||||
}
|
||||
|
||||
.active-highlight {
|
||||
background-color: #2f2f2f;
|
||||
background-color: rgb(255 255 255 / 9%);
|
||||
background-color: var(--side-nav-item-color);
|
||||
width: 0.25rem;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
@ -203,7 +202,7 @@
|
||||
padding-left: 1.125rem;
|
||||
|
||||
.side-nav-header {
|
||||
color: #ffffff;
|
||||
color: var(--side-nav-header-text-color);
|
||||
font-size: 1rem;
|
||||
margin-left: unset;
|
||||
|
||||
@ -227,7 +226,7 @@
|
||||
text-align: unset;
|
||||
margin-left: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: #999999;
|
||||
color: var(--side-nav-text-color);
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
@ -238,6 +237,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.side-nav-text {
|
||||
color: var(--side-nav-hover-text-color);
|
||||
}
|
||||
}
|
||||
.card-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -244,7 +244,8 @@
|
||||
--side-nav-mobile-box-shadow: 3px 0em 5px 10em rgb(0 0 0 / 50%);
|
||||
--side-nav-hover-text-color: white;
|
||||
--side-nav-hover-bg-color: black;
|
||||
--side-nav-text-color: hsla(0,0%,100%,.85);
|
||||
--side-nav-text-color: hsla(0, 0%, 100%, .85);
|
||||
--side-nav-header-text-color: white;
|
||||
--side-nav-border-radius: 3px;
|
||||
--side-nav-border: none;
|
||||
--side-nav-border-closed: none;
|
||||
@ -256,8 +257,10 @@
|
||||
--side-nav-item-active-text-color: #fff;
|
||||
--side-nav-active-bg-color: transparent;
|
||||
--side-nav-overlay-color: var(--elevation-layer11-dark);
|
||||
--side-nav-item-color: rgb(255 255 255 / 9%);
|
||||
--side-nav-item-closed-color: var(--elevation-layer10);
|
||||
--side-nav-item-closed-hover-color: white;
|
||||
--pref-side-nav-header-text-color: #d5d5d5;
|
||||
|
||||
/* List items */
|
||||
--list-group-item-text-color: var(--body-text-color);
|
||||
@ -353,6 +356,11 @@
|
||||
--card-overlay-bg-color: rgba(0, 0, 0, 0);
|
||||
--card-overlay-hover-bg-color: rgba(30,30,30,.6);
|
||||
--card-progress-triangle-size: 28px;
|
||||
--card-body-bg-color: rgba(0,0,0,0.7);
|
||||
--card-title-text-color: var(--card-text-color);
|
||||
--card-overlay-text-color: var(--card-text-color);
|
||||
--card-hover-text-color: var(--card-text-color);
|
||||
--card-hover-bg-color: #3a3a3a;
|
||||
|
||||
/* Slider */
|
||||
--slider-text-color: white;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user