More Epub Fixes (#4017)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-09-05 09:45:16 -05:00 committed by GitHub
parent b6c5987185
commit 8def92ff73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
172 changed files with 2204 additions and 1520 deletions

View File

@ -30,7 +30,6 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra
.EnableSensitiveDataLogging() .EnableSensitiveDataLogging()
.Options; .Options;
var connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
var context = new DataContext(contextOptions); var context = new DataContext(contextOptions);
await context.Database.EnsureCreatedAsync(); await context.Database.EnsureCreatedAsync();
@ -59,9 +58,7 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra
{ {
try try
{ {
await context.Database.EnsureCreatedAsync();
var filesystem = CreateFileSystem(); var filesystem = CreateFileSystem();
await Seed.SeedSettings(context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem)); await Seed.SeedSettings(context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync(); var setting = await context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Data; using API.Data;
using API.Entities; using API.Entities;
using API.Entities.Enums; using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers; using API.Helpers;
using API.Helpers.Builders; using API.Helpers.Builders;
using API.Services; using API.Services;
@ -158,6 +159,58 @@ public class SeriesRepositoryTests
} }
} }
[Theory]
[InlineData(12345, null, 12345)] // Case 1: Prioritize existing ExternalSeries id
[InlineData(0, "https://anilist.co/manga/100664/Ijiranaide-Nagatorosan/", 100664)] // Case 2: Extract from weblink if no external series id
[InlineData(0, "", null)] // Case 3: Return null if neither exist
public async Task GetPlusSeriesDto_Should_PrioritizeAniListId_Correctly(int externalAniListId, string? webLinks, int? expectedAniListId)
{
// Arrange
await ResetDb();
var series = new SeriesBuilder("Test Series")
.WithFormat(MangaFormat.Archive)
.Build();
var library = new LibraryBuilder("Test Library", LibraryType.Manga)
.WithFolderPath(new FolderPathBuilder("C:/data/manga/").Build())
.WithSeries(series)
.Build();
// Set up ExternalSeriesMetadata
series.ExternalSeriesMetadata = new ExternalSeriesMetadata()
{
AniListId = externalAniListId,
CbrId = 0,
MalId = 0,
GoogleBooksId = string.Empty
};
// Set up SeriesMetadata with WebLinks
series.Metadata = new SeriesMetadata()
{
WebLinks = webLinks,
ReleaseYear = 2021
};
_unitOfWork.LibraryRepository.Add(library);
_unitOfWork.SeriesRepository.Add(series);
await _unitOfWork.CommitAsync();
// Act
var result = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id);
// Assert
Assert.NotNull(result);
Assert.Equal(expectedAniListId, result.AniListId);
Assert.Equal("Test Series", result.SeriesName);
}
// TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck) // TODO: GetSeriesDtoForLibraryIdV2Async Tests (On Deck)
} }

View File

@ -4,7 +4,7 @@
/// <summary> /// <summary>
/// Represents information about a potential Series for Kavita+ /// Represents information about a potential Series for Kavita+
/// </summary> /// </summary>
public sealed record PlusSeriesRequestDto public class PlusSeriesRequestDto
{ {
public int? AniListId { get; set; } public int? AniListId { get; set; }
public long? MalId { get; set; } public long? MalId { get; set; }

View File

@ -752,7 +752,10 @@ public class SeriesRepository : ISeriesRepository
public async Task<PlusSeriesRequestDto?> GetPlusSeriesDto(int seriesId) public async Task<PlusSeriesRequestDto?> GetPlusSeriesDto(int seriesId)
{ {
return await _context.Series
// I need to check Weblinks when AniListId/MalId is already set in ExternalSeries
// Updating stale data should prioritize ExernalSeriesMetada before Weblinks, to priorize prior matches
var result = await _context.Series
.Where(s => s.Id == seriesId) .Where(s => s.Id == seriesId)
.Include(s => s.ExternalSeriesMetadata) .Include(s => s.ExternalSeriesMetadata)
.Select(series => new PlusSeriesRequestDto() .Select(series => new PlusSeriesRequestDto()
@ -760,13 +763,16 @@ public class SeriesRepository : ISeriesRepository
MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format), MediaFormat = series.Library.Type.ConvertToPlusMediaFormat(series.Format),
SeriesName = series.Name, SeriesName = series.Name,
AltSeriesName = series.LocalizedName, AltSeriesName = series.LocalizedName,
AniListId = ScrobblingService.ExtractId<int?>(series.Metadata.WebLinks, AniListId = series.ExternalSeriesMetadata.AniListId != 0
ScrobblingService.AniListWeblinkWebsite), ? series.ExternalSeriesMetadata.AniListId
MalId = ScrobblingService.ExtractId<long?>(series.Metadata.WebLinks, : ScrobblingService.ExtractId<int?>(series.Metadata.WebLinks, ScrobblingService.AniListWeblinkWebsite),
ScrobblingService.MalWeblinkWebsite), MalId = series.ExternalSeriesMetadata.MalId != 0
? series.ExternalSeriesMetadata.MalId
: ScrobblingService.ExtractId<long?>(series.Metadata.WebLinks, ScrobblingService.MalWeblinkWebsite),
CbrId = series.ExternalSeriesMetadata.CbrId, CbrId = series.ExternalSeriesMetadata.CbrId,
GoogleBooksId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks, GoogleBooksId = !string.IsNullOrEmpty(series.ExternalSeriesMetadata.GoogleBooksId)
ScrobblingService.GoogleBooksWeblinkWebsite), ? series.ExternalSeriesMetadata.GoogleBooksId
: ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks, ScrobblingService.GoogleBooksWeblinkWebsite),
MangaDexId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks, MangaDexId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
ScrobblingService.MangaDexWeblinkWebsite), ScrobblingService.MangaDexWeblinkWebsite),
VolumeCount = series.Volumes.Count, VolumeCount = series.Volumes.Count,
@ -774,6 +780,8 @@ public class SeriesRepository : ISeriesRepository
Year = series.Metadata.ReleaseYear Year = series.Metadata.ReleaseYear
}) })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
return result;
} }
public async Task<int> GetCountAsync() public async Task<int> GetCountAsync()

View File

@ -121,8 +121,17 @@ public class ExternalMetadataService : IExternalMetadataService
{ {
// Find all Series that are eligible and limit // Find all Series that are eligible and limit
var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25); var ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25);
if (ids.Count == 0) return; if (ids.Count == 0)
ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true); {
ids = await _unitOfWork.ExternalSeriesMetadataRepository.GetSeriesThatNeedExternalMetadata(25, true);
}
if (ids.Count == 0)
{
_logger.LogInformation("[Kavita+ Data Refresh] No series need matching or refreshing (stale data)");
return;
}
_logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids)); _logger.LogInformation("[Kavita+ Data Refresh] Started Refreshing {Count} series data from Kavita+: {Ids}", ids.Count, string.Join(',', ids));
var count = 0; var count = 0;
@ -137,7 +146,7 @@ public class ExternalMetadataService : IExternalMetadataService
count++; count++;
successfulMatches.Add(seriesId); successfulMatches.Add(seriesId);
} }
await Task.Delay(6000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request await Task.Delay(10000); // Currently AL is degraded and has 30 requests/min, give a little padding since this is a background request
} }
_logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches)); _logger.LogInformation("[Kavita+ Data Refresh] Finished Refreshing {Count} / {Total} series data from Kavita+: {Ids}", count, ids.Count, string.Join(',', successfulMatches));
} }

2047
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -18,17 +18,17 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-slider/ngx-slider": "^20.0.0", "@angular-slider/ngx-slider": "^20.0.0",
"@angular/animations": "^20.1.4", "@angular/animations": "^20.2.3",
"@angular/cdk": "^20.1.4", "@angular/cdk": "^20.2.1",
"@angular/common": "^20.1.4", "@angular/common": "^20.2.3",
"@angular/compiler": "^20.1.4", "@angular/compiler": "^20.2.3",
"@angular/core": "^20.1.4", "@angular/core": "^20.2.3",
"@angular/forms": "^20.1.4", "@angular/forms": "^20.2.3",
"@angular/localize": "^20.1.4", "@angular/localize": "^20.2.3",
"@angular/platform-browser": "^20.1.4", "@angular/platform-browser": "^20.2.3",
"@angular/platform-browser-dynamic": "^20.1.4", "@angular/platform-browser-dynamic": "^20.2.3",
"@angular/router": "^20.1.4", "@angular/router": "^20.2.3",
"@fortawesome/fontawesome-free": "^7.0.0", "@fortawesome/fontawesome-free": "^7.0.1",
"@iharbeck/ngx-virtual-scroller": "^19.0.1", "@iharbeck/ngx-virtual-scroller": "^19.0.1",
"@iplab/ngx-color-picker": "^20.0.0", "@iplab/ngx-color-picker": "^20.0.0",
"@iplab/ngx-file-upload": "^20.0.0", "@iplab/ngx-file-upload": "^20.0.0",
@ -43,6 +43,7 @@
"@siemens/ngx-datatable": "^22.4.1", "@siemens/ngx-datatable": "^22.4.1",
"@swimlane/ngx-charts": "^23.0.0-alpha.0", "@swimlane/ngx-charts": "^23.0.0-alpha.0",
"@tweenjs/tween.js": "^25.0.0", "@tweenjs/tween.js": "^25.0.0",
"afterframe": "^1.0.2",
"bootstrap": "^5.3.2", "bootstrap": "^5.3.2",
"charts.css": "^1.2.0", "charts.css": "^1.2.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@ -68,16 +69,16 @@
"@angular-eslint/eslint-plugin-template": "^20.1.1", "@angular-eslint/eslint-plugin-template": "^20.1.1",
"@angular-eslint/schematics": "^20.1.1", "@angular-eslint/schematics": "^20.1.1",
"@angular-eslint/template-parser": "^20.1.1", "@angular-eslint/template-parser": "^20.1.1",
"@angular/build": "^20.1.4", "@angular/build": "^20.2.1",
"@angular/cli": "^20.1.4", "@angular/cli": "^20.2.1",
"@angular/compiler-cli": "^20.1.4", "@angular/compiler-cli": "^20.2.3",
"@types/d3": "^7.4.3", "@types/d3": "^7.4.3",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
"@types/node": "^24.0.14", "@types/node": "^24.0.14",
"@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0", "@typescript-eslint/parser": "^8.42.0",
"eslint": "^9.31.0", "eslint": "^9.31.0",
"jsonminify": "^0.4.2", "jsonminify": "^0.4.2",
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",

View File

@ -1,4 +1,4 @@
import {HttpClient, HttpHeaders} from '@angular/common/http'; import {HttpClient} from '@angular/common/http';
import {DestroyRef, inject, Injectable} from '@angular/core'; import {DestroyRef, inject, Injectable} from '@angular/core';
import {Observable, of, ReplaySubject, shareReplay} from 'rxjs'; import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
import {filter, map, switchMap, tap} from 'rxjs/operators'; import {filter, map, switchMap, tap} from 'rxjs/operators';
@ -48,6 +48,10 @@ export class AccountService {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly licenseService = inject(LicenseService); private readonly licenseService = inject(LicenseService);
private readonly localizationService = inject(LocalizationService); private readonly localizationService = inject(LocalizationService);
private readonly httpClient = inject(HttpClient);
private readonly router = inject(Router);
private readonly messageHub = inject(MessageHubService);
private readonly themeService = inject(ThemeService);
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
userKey = 'kavita-user'; userKey = 'kavita-user';
@ -72,9 +76,8 @@ export class AccountService {
private isOnline: boolean = true; private isOnline: boolean = true;
constructor(private httpClient: HttpClient, private router: Router, constructor() {
private messageHub: MessageHubService, private themeService: ThemeService) { this.messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate),
map(evt => evt.payload as UserUpdateEvent), map(evt => evt.payload as UserUpdateEvent),
filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username),
switchMap(() => this.refreshAccount())) switchMap(() => this.refreshAccount()))

View File

@ -1,8 +1,5 @@
import {inject, Injectable, signal} from '@angular/core'; import {inject, Injectable, signal} from '@angular/core';
import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap"; import {NgbOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {
ViewAnnotationsDrawerComponent
} from "../book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component";
import { import {
LoadPageEvent, LoadPageEvent,
ViewBookmarkDrawerComponent ViewBookmarkDrawerComponent
@ -50,11 +47,16 @@ export class EpubReaderMenuService {
} }
openViewAnnotationsDrawer(loadAnnotationCallback: (annotation: Annotation) => void) { async openViewAnnotationsDrawer(loadAnnotationCallback: (annotation: Annotation) => void) {
if (this.offcanvasService.hasOpenOffcanvas()) { if (this.offcanvasService.hasOpenOffcanvas()) {
this.offcanvasService.dismiss(); this.offcanvasService.dismiss();
} }
// This component needs to be imported dynamically as something breaks within Angular if it's not.
// I do not know what, but this fixes the drawer from not showing up in a production build.
const module = await import('../book-reader/_components/_drawers/view-annotations-drawer/view-annotations-drawer.component');
const ViewAnnotationsDrawerComponent = module.ViewAnnotationsDrawerComponent;
const ref = this.offcanvasService.open(ViewAnnotationsDrawerComponent, {position: 'end'}); const ref = this.offcanvasService.open(ViewAnnotationsDrawerComponent, {position: 'end'});
ref.componentInstance.loadAnnotation.subscribe((annotation: Annotation) => { ref.componentInstance.loadAnnotation.subscribe((annotation: Annotation) => {
loadAnnotationCallback(annotation); loadAnnotationCallback(annotation);

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; prefix: 'actionable'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title"> <h4 class="modal-title">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; prefix: 'actionable'">
@if (actions.length > 0) { @if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" <button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'series-detail'"> <ng-container *transloco="let t; prefix: 'series-detail'">
@if(mobileSeriesImgBackground === 'true') { @if(mobileSeriesImgBackground === 'true') {
<app-image [styles]="{'background': 'none'}" [imageUrl]="coverImage"></app-image> <app-image [styles]="{'background': 'none'}" [imageUrl]="coverImage"></app-image>
} @else { } @else {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'details-tab'"> <ng-container *transloco="let t; prefix: 'details-tab'">
<div class="details pb-3"> <div class="details pb-3">
@if (readingTime) { @if (readingTime) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'edit-chapter-modal'"> <ng-container *transloco="let t; prefix: 'edit-chapter-modal'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title></h4> <h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="chapter" [prioritizeTitleName]="false"></app-entity-title></h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'edit-volume-modal'"> <ng-container *transloco="let t; prefix: 'edit-volume-modal'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="volume" [prioritizeTitleName]="false"></app-entity-title></h4> <h4 class="modal-title">{{t('title')}} <app-entity-title [libraryType]="libraryType" [entity]="volume" [prioritizeTitleName]="false"></app-entity-title></h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'match-series-modal'"> <ng-container *transloco="let t; prefix:'match-series-modal'">
<div> <div>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title"> <h4 class="modal-title" id="modal-basic-title">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'match-series-result-item'"> <ng-container *transloco="let t; prefix:'match-series-result-item'">
<div class="match-item-container p-3 mt-3 {{isDarkMode ? 'dark' : 'light'}}"> <div class="match-item-container p-3 mt-3 {{isDarkMode ? 'dark' : 'light'}}">
<div class="d-flex clickable match-item" (click)="selectItem()"> <div class="d-flex clickable match-item" (click)="selectItem()">
<div class="me-1"> <div class="me-1">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'related-tab'"> <ng-container *transloco="let t; prefix: 'related-tab'">
<div class="pb-2"> <div class="pb-2">
@if (relations.length > 0) { @if (relations.length > 0) {
<app-carousel-reel [items]="relations" [title]="t('relations-title')"> <app-carousel-reel [items]="relations" [title]="t('relations-title')">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'review-card-modal'"> <ng-container *transloco="let t; prefix:'review-card-modal'">
<div> <div>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title"> <h4 class="modal-title" id="modal-basic-title">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'review-card'"> <ng-container *transloco="let t; prefix:'review-card'">
<div class="card review-card clickable mb-3" (click)="showModal()"> <div class="card review-card clickable mb-3" (click)="showModal()">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-2 col-sm-2 col-2 d-block p-2"> <div class="col-md-2 col-sm-2 col-2 d-block p-2">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'review-modal'"> <ng-container *transloco="let t; prefix:'review-modal'">
<div> <div>
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>

View File

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnInit} from '@angular/core'; import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input} from '@angular/core';
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component"; import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {ReviewCardComponent} from "../review-card/review-card.component"; import {ReviewCardComponent} from "../review-card/review-card.component";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
@ -6,13 +6,13 @@ import {UserReview} from "../review-card/user-review";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import { import {
ReviewModalComponent, ReviewModalCloseAction, ReviewModalCloseAction,
ReviewModalCloseEvent ReviewModalCloseEvent,
ReviewModalComponent
} from "../review-modal/review-modal.component"; } from "../review-modal/review-modal.component";
import {DefaultModalOptions} from "../../_models/default-modal-options"; import {DefaultModalOptions} from "../../_models/default-modal-options";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {Series} from "../../_models/series"; import {Series} from "../../_models/series";
import {Volume} from "../../_models/volume";
import {Chapter} from "../../_models/chapter"; import {Chapter} from "../../_models/chapter";
@Component({ @Component({
@ -28,19 +28,19 @@ import {Chapter} from "../../_models/chapter";
}) })
export class ReviewsComponent { export class ReviewsComponent {
private readonly accountService = inject(AccountService);
private readonly modalService = inject(NgbModal);
private readonly cdRef = inject(ChangeDetectorRef);
@Input({required: true}) userReviews!: Array<UserReview>; @Input({required: true}) userReviews!: Array<UserReview>;
@Input({required: true}) plusReviews!: Array<UserReview>; @Input({required: true}) plusReviews!: Array<UserReview>;
@Input({required: true}) series!: Series; @Input({required: true}) series!: Series;
@Input() volumeId: number | undefined; @Input() volumeId: number | undefined;
@Input() chapter: Chapter | undefined; @Input() chapter: Chapter | undefined;
user: User | undefined; user: User | undefined = undefined;
constructor(
private accountService: AccountService,
private modalService: NgbModal,
private cdRef: ChangeDetectorRef) {
constructor() {
this.accountService.currentUser$.subscribe(user => { this.accountService.currentUser$.subscribe(user => {
if (user) { if (user) {
this.user = user; this.user = user;

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'spoiler'"> <ng-container *transloco="let t; prefix:'spoiler'">
<div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0"> <div (click)="toggle()" [attr.aria-expanded]="!isCollapsed" class="btn spoiler" tabindex="0">
@if (isCollapsed) { @if (isCollapsed) {
<span>{{t('click-to-show')}}</span> <span>{{t('click-to-show')}}</span>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'user-scrobble-history'"> <ng-container *transloco="let t; prefix:'user-scrobble-history'">
@let currentUser = accountService.currentUser$ | async; @let currentUser = accountService.currentUser$ | async;

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'copy-settings-from-library-modal'"> <ng-container *transloco="let t; prefix:'copy-settings-from-library-modal'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'directory-picker'"> <ng-container *transloco="let t; prefix:'directory-picker'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'library-access-modal'"> <ng-container *transloco="let t; prefix:'library-access-modal'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'reset-password-modal'"> <ng-container *transloco="let t; prefix:'reset-password-modal'">
<form [formGroup]="resetPasswordForm"> <form [formGroup]="resetPasswordForm">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title', {username: member.username | sentenceCase})}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title', {username: member.username | sentenceCase})}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'email-history'"> <ng-container *transloco="let t; prefix:'email-history'">
<p>{{t('description')}}</p> <p>{{t('description')}}</p>
<ngx-datatable <ngx-datatable

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'invite-user'"> <ng-container *transloco="let t; prefix: 'invite-user'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'library-selector'"> <ng-container *transloco="let t; prefix: 'library-selector'">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div class="col-auto"> <div class="col-auto">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'license'"> <ng-container *transloco="let t; prefix: 'license'">
<div class="position-relative"> <div class="position-relative">
<a class="position-absolute custom-position btn btn-outline-primary" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a> <a class="position-absolute custom-position btn btn-outline-primary" [href]="WikiLink.KavitaPlusFAQ" target="_blank" rel="noreferrer nofollow">{{t('faq-title')}}</a>
@ -7,14 +7,16 @@
<div> <div>
<p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p> <p>{{t('kavita+-desc-part-1')}} <a [href]="WikiLink.KavitaPlus" target="_blank" rel="noreferrer nofollow">{{t('kavita+-desc-part-2')}}</a> {{t('kavita+-desc-part-3')}}</p>
<p>{{t('kavita+-warning')}}</p>
<form [formGroup]="formGroup"> <form [formGroup]="formGroup">
<div class="mt-2"> <div class="mt-2">
<app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode" [showEdit]="hasLicense" [fixedExtras]="true"> <app-setting-item [title]="t('title')" (editMode)="updateEditMode($event)" [isEditMode]="!isViewMode()" [showEdit]="hasLicense()" [fixedExtras]="true">
<ng-template #titleExtra> <ng-template #titleExtra>
<button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)"> <button class="btn btn-icon btn-sm" (click)="loadLicenseInfo(true)">
@if (isChecking) { @if (isChecking()) {
<app-loading [loading]="isChecking" size="spinner-border-sm"></app-loading> <app-loading [loading]="isChecking()" size="spinner-border-sm"></app-loading>
} @else if (hasLicense) { } @else if (hasLicense()) {
<span> <span>
<i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i> <i class="fa-solid fa-refresh" tabindex="0" [ngbTooltip]="t('check')"></i>
</span> </span>
@ -22,15 +24,15 @@
</button> </button>
</ng-template> </ng-template>
<ng-template #view> <ng-template #view>
@if (hasLicense) { @if (hasLicense()) {
<span class="me-1">*********</span> <span class="me-1">*********</span>
@if (isChecking) { @if (isChecking()) {
<div class="spinner-border spinner-border-sm text-primary" role="status"> <div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">{{t('loading')}}</span> <span class="visually-hidden">{{t('loading')}}</span>
</div> </div>
} @else { } @else {
@if (licenseInfo?.isActive) { @if (licenseInfo()?.isActive) {
<i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1"> <i [ngbTooltip]="t('license-valid')" class="fa-solid fa-check-circle successful-validation ms-1">
<span class="visually-hidden">{{t('license-valid')}}</span> <span class="visually-hidden">{{t('license-valid')}}</span>
</i> </i>
@ -40,7 +42,7 @@
</i> </i>
} }
} }
@if (!isChecking && hasLicense && !licenseInfo) { @if (!isChecking() && hasLicense() && !licenseInfo) {
<div><span class="error">{{t('license-mismatch')}}</span></div> <div><span class="error">{{t('license-mismatch')}}</span></div>
} }
@ -86,29 +88,29 @@
</button> </button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header" <button type="submit" class="flex-fill btn btn-primary" aria-describedby="license-key-header"
[disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()"> [disabled]="!formGroup.get('email')?.value || !formGroup.get('licenseKey')?.value" (click)="saveForm()">
@if (!isSaving) { @if (!isSaving()) {
<span>{{t('activate-save')}}</span> <span>{{t('activate-save')}}</span>
} }
<app-loading [loading]="isSaving" size="spinner-border-sm"></app-loading> <app-loading [loading]="isSaving()" size="spinner-border-sm"></app-loading>
</button> </button>
</div> </div>
</ng-template> </ng-template>
<ng-template #titleActions> <ng-template #titleActions>
@if (hasLicense) { @if (hasLicense()) {
@if (licenseInfo?.isActive) { @if (licenseInfo()?.isActive) {
<a class="btn btn-outline-primary btn-sm me-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a> <a class="btn btn-outline-primary btn-sm me-1" [href]="manageLink()" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
} @else { } @else {
<a class="btn btn-outline-primary btn-sm me-1" <a class="btn btn-outline-primary btn-sm me-1"
[ngbTooltip]="t('invalid-license-tooltip')" [ngbTooltip]="t('invalid-license-tooltip')"
href="mailto:kavitareader@gmail.com?subject=Kavita+Subscription+Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A" href="mailto:kavitareader@gmail.com?subject=Kavita%20Subscription%20Renewal&body=Description%3A%0D%0A%0D%0ALicense%20Key%3A%0D%0A%0D%0AYour%20Email%3A"
>{{t('renew')}}</a> >{{t('renew')}}</a>
} }
} @else { } @else {
<a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a> <a class="btn btn-secondary btn-sm me-1" [href]="buyLink" target="_blank" rel="noreferrer nofollow">{{t('buy')}}</a>
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? t('activate') : t('cancel')}}</button> <button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode() ? t('activate') : t('cancel')}}</button>
} }
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
@ -116,8 +118,9 @@
</form> </form>
@let licInfo = licenseInfo();
@if (hasLicense() && licInfo) {
@if (hasLicense && licenseInfo) {
<div class="setting-section-break"></div> <div class="setting-section-break"></div>
<div class="row g-0 mt-3"> <div class="row g-0 mt-3">
@ -125,11 +128,11 @@
<div class="mb-2 col-md-6 col-sm-12"> <div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')"> <app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('license-active-label')">
<ng-template #view> <ng-template #view>
@if (isChecking) { @if (isChecking()) {
{{null | defaultValue}} {{null | defaultValue}}
} @else { } @else {
<i class="fas {{licenseInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}"> <i class="fas {{licInfo.isActive ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{licenseInfo.isActive ? t('valid') : t('invalid')}]</span> <span class="visually-hidden">{{licInfo.isActive ? t('valid') : t('invalid')}]</span>
</i> </i>
} }
</ng-template> </ng-template>
@ -139,7 +142,7 @@
<div class="mb-2 col-md-6 col-sm-12"> <div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')"> <app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('supported-version-label')">
<ng-template #view> <ng-template #view>
<i class="fas {{licenseInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}"> <i class="fas {{licInfo.isValidVersion ? 'fa-check-circle' : 'fa-circle-xmark error'}}">
<span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span> <span class="visually-hidden">{{isValidVersion ? t('valid') : t('invalid')}]</span>
</i> </i>
</ng-template> </ng-template>
@ -149,7 +152,7 @@
<div class="mb-2 col-md-6 col-sm-12"> <div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')"> <app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('expiration-label')">
<ng-template #view> <ng-template #view>
{{licenseInfo.expirationDate | utcToLocalTime | defaultValue}} {{licInfo.expirationDate | utcToLocalTime | defaultValue}}
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
</div> </div>
@ -157,7 +160,7 @@
<div class="mb-2 col-md-6 col-sm-12"> <div class="mb-2 col-md-6 col-sm-12">
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')"> <app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('total-subbed-months-label')">
<ng-template #view> <ng-template #view>
{{licenseInfo.totalMonthsSubbed | number}} {{licInfo.totalMonthsSubbed | number}}
</ng-template> </ng-template>
</app-setting-item> </app-setting-item>
</div> </div>
@ -166,8 +169,8 @@
<app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')"> <app-setting-item [canEdit]="false" [showEdit]="false" [title]="t('email-label')">
<ng-template #view> <ng-template #view>
<span (click)="toggleEmailShow()" class="col-12 clickable"> <span (click)="toggleEmailShow()" class="col-12 clickable">
@if (showEmail) { @if (showEmail()) {
{{licenseInfo.registeredEmail}} {{licInfo.registeredEmail}}
} @else { } @else {
*************** ***************
} }
@ -181,9 +184,15 @@
<!-- Actions around license --> <!-- Actions around license -->
<h3>{{t('actions-title')}}</h3> <h3>{{t('actions-title')}}</h3>
<div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('cancel-tooltip')">
<a class="btn btn-danger btn-sm mt-1" [href]="manageLink()" target="_blank" rel="noreferrer nofollow">{{t('cancel-label')}}</a>
</app-setting-button>
</div>
<div class="mt-2 mb-2"> <div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('delete-tooltip')"> <app-setting-button [subtitle]="t('delete-tooltip')">
<button type="button" class="flex-fill btn btn-danger mt-1" aria-describedby="license-key-header" (click)="deleteLicense()"> <button type="button" class="flex-fill btn btn-danger btn-sm mt-1" aria-describedby="license-key-header" (click)="deleteLicense()">
{{t('activate-delete')}} {{t('activate-delete')}}
</button> </button>
</app-setting-button> </app-setting-button>
@ -191,7 +200,7 @@
<div class="mt-2 mb-2"> <div class="mt-2 mb-2">
<app-setting-button [subtitle]="t('manage-tooltip')"> <app-setting-button [subtitle]="t('manage-tooltip')">
<a class="btn btn-primary btn-sm mt-1" [href]="manageLink" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a> <a class="btn btn-primary btn-sm mt-1" [href]="manageLink()" target="_blank" rel="noreferrer nofollow">{{t('manage')}}</a>
</app-setting-button> </app-setting-button>
</div> </div>
</div> </div>

View File

@ -1,15 +1,10 @@
import { import {ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, inject, model, OnInit} from '@angular/core';
ChangeDetectionStrategy, import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
ChangeDetectorRef,
Component, DestroyRef, inject,
OnInit
} from '@angular/core';
import { FormControl, FormGroup, Validators, ReactiveFormsModule } from "@angular/forms";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ToastrService} from "ngx-toastr"; import {ToastrService} from "ngx-toastr";
import {ConfirmService} from "../../shared/confirm.service"; import {ConfirmService} from "../../shared/confirm.service";
import { LoadingComponent } from '../../shared/loading/loading.component'; import {LoadingComponent} from '../../shared/loading/loading.component';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {environment} from "../../../environments/environment"; import {environment} from "../../../environments/environment";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {WikiLink} from "../../_models/wiki"; import {WikiLink} from "../../_models/wiki";
@ -34,58 +29,59 @@ import {LicenseService} from "../../_services/license.service";
export class LicenseComponent implements OnInit { export class LicenseComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService); private readonly confirmService = inject(ConfirmService);
protected readonly accountService = inject(AccountService); protected readonly accountService = inject(AccountService);
protected readonly licenseService = inject(LicenseService); protected readonly licenseService = inject(LicenseService);
protected readonly WikiLink = WikiLink; protected readonly WikiLink = WikiLink;
protected readonly buyLink = environment.buyLink;
formGroup: FormGroup = new FormGroup({}); formGroup: FormGroup = new FormGroup({
isViewMode: boolean = true; 'licenseKey': new FormControl('', [Validators.required]),
isChecking: boolean = true; 'email': new FormControl('', [Validators.required]),
isSaving: boolean = false; 'discordId': new FormControl('', [Validators.pattern(/\d+/)])
});
isViewMode = model<boolean>(true);
isChecking = model<boolean>(true);
isSaving = model<boolean>(false);
hasLicense = model<boolean>(false);
licenseInfo = model<LicenseInfo | null>(null);
showEmail = model<boolean>(false);
/**
* Either the normal manageLink or with a prefilled email to ease the user
*/
readonly manageLink = computed(() => {
const email = this.licenseInfo()?.registeredEmail;
if (!email) return environment.manageLink;
return environment.manageLink + '?prefilled_email=' + encodeURIComponent(email);
})
hasLicense: boolean = false;
licenseInfo: LicenseInfo | null = null;
showEmail: boolean = false;
buyLink = environment.buyLink;
manageLink = environment.manageLink;
ngOnInit(): void { ngOnInit(): void {
this.formGroup.addControl('licenseKey', new FormControl('', [Validators.required]));
this.formGroup.addControl('email', new FormControl('', [Validators.required]));
this.formGroup.addControl('discordId', new FormControl('', [Validators.pattern(/\d+/)]));
this.loadLicenseInfo().subscribe(); this.loadLicenseInfo().subscribe();
} }
loadLicenseInfo(forceCheck = false) { loadLicenseInfo(forceCheck = false) {
this.isChecking = true; this.isChecking.set(true);
this.cdRef.markForCheck();
return this.licenseService.hasAnyLicense() return this.licenseService.hasAnyLicense()
.pipe( .pipe(
tap(res => { tap(res => {
this.hasLicense = res; this.hasLicense.set(res);
this.isChecking = false; this.isChecking.set(false);
this.cdRef.markForCheck();
}), }),
filter(hasLicense => hasLicense), filter(hasLicense => hasLicense),
tap(_ => { tap(_ => {
this.isChecking = true; this.isChecking.set(true);
this.cdRef.markForCheck();
}), }),
switchMap(_ => this.licenseService.licenseInfo(forceCheck)), switchMap(_ => this.licenseService.licenseInfo(forceCheck)),
tap(licenseInfo => { tap(licenseInfo => {
this.licenseInfo = licenseInfo; this.licenseInfo.set(licenseInfo);
this.isChecking = false; this.isChecking.set(false);
this.cdRef.markForCheck();
}) })
); );
} }
@ -99,30 +95,37 @@ export class LicenseComponent implements OnInit {
} }
saveForm() { saveForm() {
this.isSaving = true; this.isSaving.set(true);
this.cdRef.markForCheck();
const hadActiveLicenseBefore = this.licenseInfo?.isActive;
this.licenseService.updateUserLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim(), this.formGroup.get('discordId')!.value.trim())
.subscribe(() => {
this.resetForm(); const hadActiveLicenseBefore = this.licenseInfo()?.isActive;
this.isViewMode = true;
this.isSaving = false; const license = this.formGroup.get('licenseKey')!.value.trim();
this.cdRef.markForCheck(); const email = this.formGroup.get('email')!.value.trim();
this.loadLicenseInfo().subscribe(async (info) => { const discordId = this.formGroup.get('discordId')!.value.trim();
if (info?.isActive && !hadActiveLicenseBefore) {
await this.confirmService.info(translate('license.k+-unlocked-description'), translate('license.k+-unlocked')); this.licenseService.updateUserLicense(license, email, discordId)
} else { .subscribe({
this.toastr.info(translate('toasts.k+-license-saved')); next: () => {
} this.resetForm();
}); this.isViewMode.set(true);
}, async (err) => { this.isSaving.set(false);
await this.handleError(err); this.cdRef.markForCheck();
this.loadLicenseInfo().subscribe(async (info) => {
if (info?.isActive && !hadActiveLicenseBefore) {
await this.confirmService.info(translate('license.k+-unlocked-description'), translate('license.k+-unlocked'));
} else {
this.toastr.info(translate('toasts.k+-license-saved'));
}
});
},
error: async err => {
await this.handleError(err);
}
}); });
} }
private async handleError(err: any) { private async handleError(err: any) {
this.isSaving = false; this.isSaving.set(false);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
if (err.hasOwnProperty('error')) { if (err.hasOwnProperty('error')) {
@ -163,8 +166,7 @@ export class LicenseComponent implements OnInit {
} }
forceSave() { forceSave() {
this.isSaving = false; this.isSaving.set(false);
this.cdRef.markForCheck();
this.licenseService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim()) this.licenseService.resetLicense(this.formGroup.get('licenseKey')!.value.trim(), this.formGroup.get('email')!.value.trim())
.subscribe(_ => { .subscribe(_ => {
@ -179,10 +181,9 @@ export class LicenseComponent implements OnInit {
this.licenseService.deleteLicense().subscribe(() => { this.licenseService.deleteLicense().subscribe(() => {
this.resetForm(); this.resetForm();
this.isViewMode = true; this.isViewMode.set(true);
this.licenseInfo = null; this.licenseInfo.set(null);
this.hasLicense = false; this.hasLicense.set(false);
//this.hasValidLicense = false;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
@ -197,38 +198,16 @@ export class LicenseComponent implements OnInit {
}); });
} }
//
// validateLicense(forceCheck = false) {
// return of().pipe(
// startWith(null),
// tap(_ => {
// this.isChecking = true;
// this.cdRef.markForCheck();
// }),
// switchMap(_ => this.licenseService.licenseInfo(forceCheck)),
// tap(licenseInfo => {
// this.licenseInfo = licenseInfo;
// //this.hasValidLicense = licenseInfo?.isActive || false;
// this.isChecking = false;
// this.cdRef.markForCheck();
// })
// )
//
// }
updateEditMode(mode: boolean) { updateEditMode(mode: boolean) {
this.isViewMode = !mode; this.isViewMode.set(!mode);
this.cdRef.markForCheck();
} }
toggleViewMode() { toggleViewMode() {
this.isViewMode = !this.isViewMode; this.isViewMode.update(v => !v);
this.cdRef.markForCheck();
this.resetForm(); this.resetForm();
} }
toggleEmailShow() { toggleEmailShow() {
this.showEmail = !this.showEmail; this.showEmail.update(v => !v);
this.cdRef.markForCheck();
} }
} }

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-email-settings'"> <ng-container *transloco="let t; prefix: 'manage-email-settings'">
<div class="position-relative"> <div class="position-relative">
<button type="button" class="btn btn-outline-primary position-absolute custom-position" (click)="test()">{{t('test')}}</button> <button type="button" class="btn btn-outline-primary position-absolute custom-position" (click)="test()">{{t('test')}}</button>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-library'"> <ng-container *transloco="let t; prefix: 'manage-library'">
<div class="position-relative"> <div class="position-relative">
<div class="position-absolute custom-position-2"> <div class="position-absolute custom-position-2">
<app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')" <app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')"

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'manage-matched-metadata'"> <ng-container *transloco="let t; prefix:'manage-matched-metadata'">
<p>{{t('description')}}</p> <p>{{t('description')}}</p>
<form [formGroup]="filterGroup"> <form [formGroup]="filterGroup">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-media-issues'"> <ng-container *transloco="let t; prefix: 'manage-media-issues'">
<p>{{t('description-part-1')}} <a rel="noopener noreferrer" target="_blank" [href]="WikiLink.MediaIssues">{{t('description-part-2')}}</a></p> <p>{{t('description-part-1')}} <a rel="noopener noreferrer" target="_blank" [href]="WikiLink.MediaIssues">{{t('description-part-2')}}</a></p>
<form [formGroup]="formGroup"> <form [formGroup]="formGroup">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-media-settings'"> <ng-container *transloco="let t; prefix: 'manage-media-settings'">
<form [formGroup]="settingsForm"> <form [formGroup]="settingsForm">
<div class="mb-4"> <div class="mb-4">
<p> <p>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'manage-metadata-settings'"> <ng-container *transloco="let t; prefix:'manage-metadata-settings'">
<p>{{t('description')}}</p> <p>{{t('description')}}</p>
@if (isLoaded) { @if (isLoaded) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-scrobble-errors'"> <ng-container *transloco="let t; prefix: 'manage-scrobble-errors'">
<h4>{{t('title')}}</h4> <h4>{{t('title')}}</h4>
<p>{{t('description')}}</p> <p>{{t('description')}}</p>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-settings'"> <ng-container *transloco="let t; prefix: 'manage-settings'">
<div class="position-relative"> <div class="position-relative">
<button type="button" class="btn btn-outline-primary position-absolute custom-position" (click)="resetToDefaults()">{{t('reset-to-default')}}</button> <button type="button" class="btn btn-outline-primary position-absolute custom-position" (click)="resetToDefaults()">{{t('reset-to-default')}}</button>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-system'"> <ng-container *transloco="let t; prefix: 'manage-system'">
@if (serverInfo) { @if (serverInfo) {
<div class="mb-3"> <div class="mb-3">
<h3>{{t('title')}}</h3> <h3>{{t('title')}}</h3>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-tasks-settings'"> <ng-container *transloco="let t; prefix: 'manage-tasks-settings'">
@if (serverSettings) { @if (serverSettings) {
<form [formGroup]="settingsForm"> <form [formGroup]="settingsForm">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'manage-user-tokens'"> <ng-container *transloco="let t; prefix:'manage-user-tokens'">
<p>{{t('description')}}</p> <p>{{t('description')}}</p>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manage-users'"> <ng-container *transloco="let t; prefix: 'manage-users'">
<div class="position-relative"> <div class="position-relative">
<button class="btn btn-outline-primary position-absolute custom-position" (click)="inviteUser()"> <button class="btn btn-outline-primary position-absolute custom-position" (click)="inviteUser()">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{t('invite')}}</span> <i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;{{t('invite')}}</span>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'role-selector'"> <ng-container *transloco="let t; prefix:'role-selector'">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div class="col-auto"> <div class="col-auto">
<h4>{{t('title')}}</h4> <h4>{{t('title')}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'all-filters'"> <ng-container *transloco="let t; prefix: 'all-filters'">
<div class="main-container"> <div class="main-container">
<app-side-nav-companion-bar [hasFilter]="false"> <app-side-nav-companion-bar [hasFilter]="false">
<h4 title> <h4 title>

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'all-series'"> <ng-container *transloco="let t; prefix: 'all-series'">
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h4 title> <h4 title>
{{title}} {{title}}

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'announcements'"> <ng-container *transloco="let t; prefix: 'announcements'">
<app-side-nav-companion-bar> <app-side-nav-companion-bar>
<h4 title> <h4 title>
{{t('title')}} {{t('title')}}

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'changelog-update-item'"> <ng-container *transloco="let t; prefix:'changelog-update-item'">
@if (update) { @if (update) {
<div class="update-details"> <div class="update-details">
@if (update.blogPart) { @if (update.blogPart) {

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'changelog'"> <ng-container *transloco="let t; prefix: 'changelog'">
<div class="changelog"> <div class="changelog">
@for(update of updates; track update; let indx = $index) { @for(update of updates; track update; let indx = $index) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'new-version-modal'"> <ng-container *transloco="let t; prefix:'new-version-modal'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
</div> </div>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'out-of-date-modal'"> <ng-container *transloco="let t; prefix:'out-of-date-modal'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'update-notification-modal'"> <ng-container *transloco="let t; prefix: 'update-notification-modal'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4> <h4 class="modal-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>

View File

@ -32,6 +32,7 @@
[selected]="slot.slotNumber === selectedSlotIndex()" [selected]="slot.slotNumber === selectedSlotIndex()"
(selectPicker)="selectSlot(index, slot)" (selectPicker)="selectSlot(index, slot)"
[editMode]="isEditMode()" [editMode]="isEditMode()"
[canChangeEditMode]="canChangeEditMode()"
(editModeChange)="isEditMode.set($event)" (editModeChange)="isEditMode.set($event)"
[first]="$first" [first]="$first"
[last]="$last" [last]="$last"

View File

@ -31,6 +31,7 @@ export class HighlightBarComponent {
isCollapsed = model<boolean>(true); isCollapsed = model<boolean>(true);
canCollapse = model<boolean>(true); canCollapse = model<boolean>(true);
isEditMode = model<boolean>(false); isEditMode = model<boolean>(false);
canChangeEditMode = model<boolean>(true);
slots = this.annotationService.slots; slots = this.annotationService.slots;
@ -53,6 +54,8 @@ export class HighlightBarComponent {
} }
toggleEditMode() { toggleEditMode() {
if (!this.canChangeEditMode()) return;
const existingEdit = this.isEditMode(); const existingEdit = this.isEditMode();
this.isEditMode.set(!existingEdit); this.isEditMode.set(!existingEdit);
} }

View File

@ -8,6 +8,6 @@
<div class="offcanvas-body"> <div class="offcanvas-body">
<app-table-of-contents [chapters]="chapters()" [chapterId]="chapterId()!" [pageNum]="pageNum()" <app-table-of-contents [chapters]="chapters()" [chapterId]="chapterId()!" [pageNum]="pageNum()"
(loadChapter)="loadChapterPage($event)" /> (loadChapter)="loadChapterPage($event)" [loading]="loading()" />
</div> </div>
</ng-container> </ng-container>

View File

@ -5,7 +5,7 @@ import {
effect, effect,
EventEmitter, EventEmitter,
inject, inject,
model model, signal
} from '@angular/core'; } from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap"; import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
@ -40,6 +40,7 @@ export class ViewTocDrawerComponent {
* The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors * The actual pages from the epub, used for showing on table of contents. This must be here as we need access to it for scroll anchors
*/ */
chapters = model<Array<BookChapterItem>>([]); chapters = model<Array<BookChapterItem>>([]);
loading = signal(true);
loadPage: EventEmitter<LoadPageEvent | null> = new EventEmitter<LoadPageEvent | null>(); loadPage: EventEmitter<LoadPageEvent | null> = new EventEmitter<LoadPageEvent | null>();
@ -54,6 +55,7 @@ export class ViewTocDrawerComponent {
} }
this.bookService.getBookChapters(id).subscribe(bookChapters => { this.bookService.getBookChapters(id).subscribe(bookChapters => {
this.loading.set(false);
this.chapters.set(bookChapters); this.chapters.set(bookChapters);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'book-line-overlay'"> <ng-container *transloco="let t; prefix: 'book-line-overlay'">
@if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) { @if(selectedText.length > 0 || mode !== BookLineOverlayMode.None) {
<div class="overlay"> <div class="overlay">

View File

@ -43,7 +43,7 @@
<div #readingHtml class="book-content {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}" <div #readingHtml class="book-content {{layoutMode() | columnLayoutClass}} {{writingStyle() | writingStyleClass}}"
[ngStyle]="{'max-height': columnHeight(), 'max-width': verticalBookContentWidth(), 'width': verticalBookContentWidth(), 'column-width': columnWidth()}" [ngStyle]="{'max-height': columnHeight(), 'max-width': verticalBookContentWidth(), 'width': verticalBookContentWidth(), 'column-width': columnWidth()}"
[ngClass]="{'immersive': immersiveMode() && actionBarVisible}" [ngClass]="{'immersive': immersiveMode() && actionBarVisible, 'debug': debugMode()}"
[innerHtml]="page()" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div> [innerHtml]="page()" (click)="toggleMenu($event)" (mousedown)="mouseDown($event)" (wheel)="onWheel($event)"></div>
@if (shouldShowBottomActionBar()) { @if (shouldShowBottomActionBar()) {
@ -89,6 +89,13 @@
<i class="fa fa-reply" aria-hidden="true"></i> <i class="fa fa-reply" aria-hidden="true"></i>
</button> </button>
} }
@if (debugMode()) {
<button class="btn btn-secondary btn-icon me-1" (click)="debugInsertViewportView()">
V
</button>
}
<button class="btn btn-secondary btn-icon me-1" (click)="viewBookmarkImages()"> <button class="btn btn-secondary btn-icon me-1" (click)="viewBookmarkImages()">
<i class="fa-solid fa-book-bookmark" aria-hidden="true"></i> <i class="fa-solid fa-book-bookmark" aria-hidden="true"></i>
</button> </button>
@ -103,14 +110,6 @@
</button> </button>
</div> </div>
</div> </div>
<div class="progress-bar-container">
@if (isLoading()) {
<ngb-progressbar type="primary" height="10px" [value]="1" [max]="1" [striped]="true"></ngb-progressbar>
} @else {
<ngb-progressbar type="primary" height="10px" [value]="virtualizedPageNum()" [max]="virtualizedMaxPages()" (click)="goToPage()" [showValue]="true"></ngb-progressbar>
}
</div>
</div> </div>
} }
</ng-template> </ng-template>
@ -146,6 +145,12 @@
</span> </span>
} }
} }
@if (debugMode()) {
@let vp = getVirtualPage();
{{vp[0] * vp[2]}} / {{vp[1] * vp[2]}}
}
</div> </div>
<button class="btn btn-outline-secondary btn-icon col-2 col-xs-1" <button class="btn btn-outline-secondary btn-icon col-2 col-xs-1"

View File

@ -1,66 +1,66 @@
@font-face { @font-face {
font-family: "Fira_Sans"; font-family: "Fira_Sans";
src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/Fira_Sans/FiraSans-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Lato"; font-family: "Lato";
src: url(../../../../assets/fonts/Lato/Lato-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/Lato/Lato-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Libre_Baskerville"; font-family: "Libre_Baskerville";
src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/Libre_Baskerville/LibreBaskerville-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Merriweather"; font-family: "Merriweather";
src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/Merriweather/Merriweather-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "Nanum_Gothic"; font-family: "Nanum_Gothic";
src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/Nanum_Gothic/NanumGothic-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "RocknRoll_One"; font-family: "RocknRoll_One";
src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "OpenDyslexic2"; font-family: "OpenDyslexic2";
src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2) format("woff2"); src: url(../../../../assets/fonts/OpenDyslexic2/OpenDyslexic-Regular.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "FastFontSerif"; font-family: "FastFontSerif";
src: url(../../../../assets/fonts/Fast_Font/Fast_Serif.woff2) format("woff2"); src: url(../../../../assets/fonts/Fast_Font/Fast_Serif.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
@font-face { @font-face {
font-family: "FastFontSans"; font-family: "FastFontSans";
src: url(../../../../assets/fonts/Fast_Font/Fast_Sans.woff2) format("woff2"); src: url(../../../../assets/fonts/Fast_Font/Fast_Sans.woff2) format("woff2");
font-display: swap; font-display: swap;
} }
:root { :root {
--br-actionbar-button-text-color: #6c757d; --br-actionbar-button-text-color: #6c757d;
--accordion-body-bg-color: black; --accordion-body-bg-color: black;
--accordion-header-bg-color: grey; --accordion-header-bg-color: grey;
--br-actionbar-button-hover-border-color: #6c757d; --br-actionbar-button-hover-border-color: #6c757d;
--br-actionbar-bg-color: white; --br-actionbar-bg-color: white;
--default-state-scrollbar: var(--primary-color-scrollbar); --default-state-scrollbar: var(--primary-color-scrollbar);
} }
@ -85,150 +85,115 @@ $action-bar-height: 38px;
} }
} }
// Drawer
.control-container {
padding-bottom: 5px;
}
.page-stub {
margin-top: 6px;
padding-left: 2px;
padding-right: 2px;
}
.drawer-body {
overflow: auto;
.reader-pills {
justify-content: center;
margin: 0 0.25rem;
li a {
border: 1px solid var(--primary-color);
margin: 0 0.25rem;
.active {
border: unset;
}
}
}
}
// Drawer End
.fixed-top { .fixed-top {
z-index: 1022; z-index: 1022;
direction: ltr; direction: ltr;
} }
.dark-mode .overlay { .dark-mode .overlay {
opacity: 0; opacity: 0;
} }
.action-bar { .action-bar {
background-color: var(--br-actionbar-bg-color); background-color: var(--br-actionbar-bg-color);
overflow: hidden;
box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%);
max-height: $action-bar-height;
height: $action-bar-height;
.book-title-text {
text-align: center;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%); }
max-height: $action-bar-height;
height: $action-bar-height;
.book-title-text {
text-align: center;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media(max-width: 875px) {
.book-title {
display: none;
}
}
@media(max-width: 875px) {
.book-title { .book-title {
margin-top: 10px; display: none;
text-align: center;
text-transform: capitalize;
max-height: inherit;
} }
}
.next-page-highlight { .book-title {
color: var(--primary-color); margin-top: 10px;
} text-align: center;
text-transform: capitalize;
max-height: inherit;
}
.next-page-highlight {
color: var(--primary-color);
}
} }
.reader-container { .reader-container {
outline: none; // Only the reading section itself shouldn't receive any outline. We use it to shift focus in fullscreen mode outline: none; // Only the reading section itself shouldn't receive any outline. We use it to shift focus in fullscreen mode
overflow: auto; overflow: auto;
height: calc(var(--vh, 1vh) * 100); height: calc(var(--vh, 1vh) * 100);
position: relative; position: relative;
// This is completely invisible, everything else renders over it // This is completely invisible, everything else renders over it
&.column-layout-1 { &.column-layout-1 {
height: calc(var(--vh) * 100); height: calc(var(--vh) * 100);
} }
&.column-layout-2 { &.column-layout-2 {
height: calc(var(--vh) * 100); height: calc(var(--vh) * 100);
} }
&.writing-style-vertical { &.writing-style-vertical {
direction: rtl; direction: rtl;
} }
} }
.reading-section { .reading-section {
width: 100%; width: 100%;
//overflow: auto; // This will break progress reporting //overflow: auto; // This will break progress reporting
height: 100vh; height: 100vh;
padding-top: $action-bar-height; padding-top: $action-bar-height;
padding-bottom: $action-bar-height; padding-bottom: $action-bar-height;
position: relative; position: relative;
direction: ltr; direction: ltr;
//background-color: green !important; //background-color: green !important;
&.column-layout-1 { &.column-layout-1 {
height: calc((var(--vh, 1vh) * 100) - $action-bar-height); height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
} }
&.column-layout-2 { &.column-layout-2 {
height: calc((var(--vh, 1vh) * 100) - $action-bar-height); height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
} }
&.immersive { &.immersive {
height: calc((var(--vh, 1vh) * 100)); height: calc((var(--vh, 1vh) * 100));
//padding-top: 0px; }
//padding-bottom: 0px;
}
&.writing-style-vertical { &.writing-style-vertical {
writing-mode: vertical-rl; writing-mode: vertical-rl;
height: 100%; height: 100%;
} }
} }
.book-container { .book-container {
position: relative; position: relative;
height: 100%; height: 100%;
//background-color: purple !important; &.column-layout-1 {
height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
}
&.column-layout-1 { &.column-layout-2 {
height: calc((var(--vh, 1vh) * 100) - $action-bar-height); height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
} }
&.column-layout-2 { &.writing-style-vertical {
height: calc((var(--vh, 1vh) * 100) - $action-bar-height); // Fixes an issue where chrome will cut of margins, doesn't seem to affect other browsers
} overflow: auto;
}
&.writing-style-vertical {
// Fixes an issue where chrome will cut of margins, doesn't seem to affect other browsers
overflow: auto;
}
} }
.book-content { .book-content {
@ -237,45 +202,50 @@ $action-bar-height: 38px;
padding: 20px 0px; padding: 20px 0px;
&.column-layout-1 { &.column-layout-1 {
height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2 height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2
&.writing-style-vertical { &.writing-style-vertical {
padding: 0 10px 0 0; padding: 0 10px 0 0;
margin: 20px 0; margin: 20px 0;
}
} }
}
&.column-layout-2 { &.column-layout-2 {
height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2 height: calc((var(--vh) * 100) - calc($action-bar-height)); // * 2
column-fill: balance;
&.writing-style-vertical { &.debug {
padding: 0 10px 0 0; column-rule: 2px dashed #666666;
margin: 20px 0;
}
} }
&.writing-style-vertical { &.writing-style-vertical {
height: auto; padding: 0 10px 0 0;
margin: 20px 0;
} }
}
// &.immersive { &.writing-style-vertical {
// // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726 height: auto;
// //height: calc((var(--vh, 1vh) * 100) - $action-bar-height); }
// }
a, :link { // &.immersive {
color: var(--brtheme-link-text-color); // // Note: I removed this for bug: https://github.com/Kareadita/Kavita/issues/1726
} // //height: calc((var(--vh, 1vh) * 100) - $action-bar-height);
// }
background-color: var(--brtheme-bg-color); a, :link {
color: var(--brtheme-link-text-color);
}
background-color: var(--brtheme-bg-color);
} }
.pagination-cont { .pagination-cont {
background: var(--br-actionbar-bg-color); background: var(--br-actionbar-bg-color);
border-radius: 5px; border-radius: 5px;
padding: 5px 15px; padding: 5px 15px;
margin: 0 0 5px; margin: 0 0 5px;
border: var(--drawer-pagination-border); border: var(--drawer-pagination-border);
} }
.virt-pagination-cont { .virt-pagination-cont {
@ -285,70 +255,74 @@ $action-bar-height: 38px;
} }
.bottom-bar { .bottom-bar {
position: fixed; position: fixed;
width: 100%; width: 100%;
bottom: 0; bottom: 0;
left: 0; left: 0;
writing-mode: horizontal-tb; writing-mode: horizontal-tb;
} }
// This is essentially fitting the text to height and when you press next you are scrolling over by page width // This is essentially fitting the text to height and when you press next you are scrolling over by page width
.column-layout-1 { .column-layout-1 {
.book-content { .book-content {
column-count: 1; column-count: 1;
column-gap: 20px; column-gap: 20px;
overflow: hidden; overflow: hidden;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
} }
.column-layout-2 { .column-layout-2 {
.book-content { .book-content {
column-count: 2; column-count: 2;
column-gap: 20px; column-gap: 20px;
overflow: hidden; overflow: hidden;
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
&.debug {
overflow: scroll;
overflow-y: hidden;
} }
}
} }
// A bunch of resets so books render correctly // A bunch of resets so books render correctly
::ng-deep .book-content { ::ng-deep .book-content {
& a, & :link { & a, & :link {
color: #8db2e5; color: #8db2e5;
} }
} }
// This is applied to images in the backend // This is applied to images in the backend
::ng-deep .kavita-scale-width-container { ::ng-deep .kavita-scale-width-container {
width: auto; width: auto;
max-height: calc(var(--book-reader-content-max-height) - ($action-bar-height)) !important; max-height: calc(var(--book-reader-content-max-height) - ($action-bar-height)) !important;
max-width: calc(var(--book-reader-content-max-width)) !important; max-width: calc(var(--book-reader-content-max-width)) !important;
position: var(--book-reader-content-position) !important; position: var(--book-reader-content-position) !important;
top: var(--book-reader-content-top) !important; top: var(--book-reader-content-top) !important;
left: var(--book-reader-content-left) !important; left: var(--book-reader-content-left) !important;
transform: var(--book-reader-content-transform) !important; transform: var(--book-reader-content-transform) !important;
} }
// This is applied to images in the backend // This is applied to images in the backend
::ng-deep .kavita-scale-width { ::ng-deep .kavita-scale-width {
max-height: calc(var(--book-reader-content-max-height) - ($action-bar-height)) !important; max-height: calc(var(--book-reader-content-max-height) - ($action-bar-height)) !important;
max-width: calc(var(--book-reader-content-max-width)) !important; max-width: calc(var(--book-reader-content-max-width)) !important;
object-fit: contain; object-fit: contain;
object-position: top center; object-position: top center;
break-inside: avoid; break-inside: avoid;
break-before: column; break-before: column;
max-height: 100vh; max-height: 100vh;
} }
// Click to Paginate styles // Click to Paginate styles
.icon-primary-color { .icon-primary-color {
color: $primary-color; color: $primary-color;
} }
$pagination-color: transparent; $pagination-color: transparent;
@ -361,63 +335,63 @@ $pagination-opacity: 0;
.right { .right {
position: absolute; position: absolute;
right: 0px; right: 0px;
top: $action-bar-height; top: $action-bar-height;
width: 20vw; width: 20vw;
z-index: 3; z-index: 3;
background: $pagination-color; background: $pagination-color;
border-color: transparent; border-color: transparent;
border: none !important; border: none !important;
opacity: 0; opacity: 0;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
&.immersive { &.immersive {
top: 0; top: 0;
} }
&.no-pointer-events { &.no-pointer-events {
pointer-events: none; pointer-events: none;
} }
} }
// This class pushes the click area to the left a bit to let users click the scrollbar // This class pushes the click area to the left a bit to let users click the scrollbar
.right-with-scrollbar { .right-with-scrollbar {
position: absolute; position: absolute;
right: 17px; right: 17px;
top: $action-bar-height; top: $action-bar-height;
width: 18%; width: 18%;
z-index: 3; z-index: 3;
background: $pagination-color; background: $pagination-color;
opacity: $pagination-opacity; opacity: $pagination-opacity;
border-color: transparent; border-color: transparent;
border: none !important; border: none !important;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
&.immersive { &.immersive {
top: 0; top: 0;
} }
} }
.left { .left {
position: absolute; position: absolute;
left: 0px; left: 0px;
top: $action-bar-height; top: $action-bar-height;
width: 20vw; width: 20vw;
background: $pagination-color; background: $pagination-color;
opacity: $pagination-opacity; opacity: $pagination-opacity;
border-color: transparent; border-color: transparent;
border: none !important; border: none !important;
z-index: 3; z-index: 3;
outline: none; outline: none;
height: 100vw; height: 100vw;
cursor: pointer; cursor: pointer;
&.immersive { &.immersive {
top: 0px; top: 0px;
} }
} }
@ -436,40 +410,40 @@ $pagination-opacity: 0;
// TODO: Figure out why book-reader has it's own button overrides // TODO: Figure out why book-reader has it's own button overrides
.btn { .btn {
&.btn-secondary { &.btn-secondary {
color: var(--br-actionbar-button-text-color); color: var(--br-actionbar-button-text-color);
border-color: transparent; border-color: transparent;
background-color: unset; background-color: unset;
&:hover, &:focus { &:hover, &:focus {
border-color: var(--br-actionbar-button-hover-border-color); border-color: var(--br-actionbar-button-hover-border-color);
}
} }
}
&.btn-outline-secondary { &.btn-outline-secondary {
border-color: transparent; border-color: transparent;
background-color: unset; background-color: unset;
&:hover, &:focus { &:hover, &:focus {
border-color: var(--br-actionbar-button-hover-border-color); border-color: var(--br-actionbar-button-hover-border-color);
}
} }
}
span { span {
background-color: unset; background-color: unset;
color: var(--br-actionbar-button-text-color); color: var(--br-actionbar-button-text-color);
} }
i { i {
background-color: unset; background-color: unset;
color: var(--br-actionbar-button-text-color); color: var(--br-actionbar-button-text-color);
} }
&:active { &:active {
* { * {
color: white; color: white;
}
} }
}
} }
@ -480,17 +454,6 @@ $pagination-opacity: 0;
background-color: var(--bs-body-bg); background-color: var(--bs-body-bg);
} }
.progress-bar-container {
border-top: 1px solid var(--bs-border-color-translucent);
background-color: var(--br-actionbar-bg-color); // TODO: BUG: This isn't coloring well
max-height: 10px;
.progress {
border-radius: 0;
background-color: var(--br-actionbar-bg-color);
}
}
.page-progress-slider { .page-progress-slider {
margin: 0; margin: 0;
} }

View File

@ -47,7 +47,7 @@ import {ThemeService} from 'src/app/_services/theme.service';
import {ScrollService} from 'src/app/_services/scroll.service'; import {ScrollService} from 'src/app/_services/scroll.service';
import {PAGING_DIRECTION} from 'src/app/manga-reader/_models/reader-enums'; import {PAGING_DIRECTION} from 'src/app/manga-reader/_models/reader-enums';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {NgbProgressbar, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.component"; import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.component";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles"; import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
@ -67,6 +67,7 @@ import {LayoutMeasurementService} from "../../../_services/layout-measurement.se
import {ColorscapeService} from "../../../_services/colorscape.service"; import {ColorscapeService} from "../../../_services/colorscape.service";
import {environment} from "../../../../environments/environment"; import {environment} from "../../../../environments/environment";
import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component"; import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
import afterFrame from "afterframe";
interface HistoryPoint { interface HistoryPoint {
@ -124,7 +125,7 @@ const SCROLL_DELAY = 10;
]) ])
], ],
imports: [NgTemplateOutlet, NgStyle, NgClass, NgbTooltip, imports: [NgTemplateOutlet, NgStyle, NgClass, NgbTooltip,
BookLineOverlayComponent, TranslocoDirective, ColumnLayoutClassPipe, WritingStyleClassPipe, ReadTimeLeftPipe, PercentPipe, NgxSliderModule, NgbProgressbar], BookLineOverlayComponent, TranslocoDirective, ColumnLayoutClassPipe, WritingStyleClassPipe, ReadTimeLeftPipe, PercentPipe, NgxSliderModule],
providers: [EpubReaderSettingsService, LayoutMeasurementService], providers: [EpubReaderSettingsService, LayoutMeasurementService],
}) })
export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
@ -354,6 +355,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/ */
firstLoad: boolean = true; firstLoad: boolean = true;
/**
* Injects information to help debug issues
*/
debugMode = model<boolean>(!environment.production && true);
@ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>; @ViewChild('bookContainer', {static: false}) bookContainerElemRef!: ElementRef<HTMLDivElement>;
@ -361,10 +367,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
* book-content class * book-content class
*/ */
@ViewChild('readingHtml', {static: false}) bookContentElemRef!: ElementRef<HTMLDivElement>; @ViewChild('readingHtml', {static: false}) bookContentElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('readingHtml', { read: ViewContainerRef }) readingContainer!: ViewContainerRef;
@ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>; @ViewChild('readingSection', {static: false}) readingSectionElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>; @ViewChild('stickyTop', {static: false}) stickyTopElemRef!: ElementRef<HTMLDivElement>;
@ViewChild('reader', {static: false}) reader!: ElementRef; @ViewChild('reader', {static: false}) reader!: ElementRef;
@ViewChild('readingHtml', { read: ViewContainerRef }) readingContainer!: ViewContainerRef;
protected readonly layoutMode = this.readerSettingsService.layoutMode; protected readonly layoutMode = this.readerSettingsService.layoutMode;
@ -525,15 +533,23 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const layoutMode = this.layoutMode(); const layoutMode = this.layoutMode();
const writingStyle = this.writingStyle(); const writingStyle = this.writingStyle();
const windowWidth = this.windowWidth();
const base = writingStyle === WritingStyle.Vertical ? this.pageHeight() : this.pageWidth(); const base = writingStyle === WritingStyle.Vertical ? this.pageHeight() : this.pageWidth();
// console.log('window width: ', windowWidth)
// console.log('book content width: ', this.readingSectionElemRef?.nativeElement?.clientWidth);
// console.log('column width: ', base / 4);
switch (layoutMode) { switch (layoutMode) {
case BookPageLayoutMode.Default: case BookPageLayoutMode.Default:
return 'unset'; return 'unset';
case BookPageLayoutMode.Column1: case BookPageLayoutMode.Column1:
return ((base / 2) - 4) + 'px'; return ((base / 2) - 4) + 'px';
case BookPageLayoutMode.Column2: case BookPageLayoutMode.Column2:
return (base / 4) + 'px' //return (this.readingSectionElemRef?.nativeElement?.clientWidth - this.getMargin() + 1) / 2 + 'px';
return (((this.readingSectionElemRef?.nativeElement?.clientWidth ?? base)) / 4) + 1 + 'px'
//return ((base) / 4) + 6 + 'px'
default: default:
return 'unset'; return 'unset';
} }
@ -562,6 +578,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (layoutMode !== BookPageLayoutMode.Default && writingStyle !== WritingStyle.Horizontal) { if (layoutMode !== BookPageLayoutMode.Default && writingStyle !== WritingStyle.Horizontal) {
console.log('verticalBookContentWidth: ', verticalPageWidth)
return `${verticalPageWidth}px`; return `${verticalPageWidth}px`;
} }
return ''; return '';
@ -974,6 +991,9 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
case KEY_CODES.F: case KEY_CODES.F:
this.applyFullscreen(); this.applyFullscreen();
break; break;
case KEY_CODES.SPACE:
this.actionBarVisible.update(x => !x);
break;
} }
} }
@ -1093,30 +1113,30 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
*/ */
addLinkClickHandlers() { addLinkClickHandlers() {
const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a'); const links = this.readingSectionElemRef.nativeElement.querySelectorAll('a');
links.forEach((link: any) => { links.forEach((link: any) => {
link.addEventListener('click', (e: any) => { link.addEventListener('click', (e: any) => {
e.stopPropagation(); e.stopPropagation();
let targetElem = e.target; let targetElem = e.target;
if (e.target.nodeName !== 'A' && e.target.parentNode.nodeName === 'A') { if (e.target.nodeName !== 'A' && e.target.parentNode.nodeName === 'A') {
// Certain combos like <a><sup>text</sup></a> can cause the target to be the sup tag and not the anchor // Certain combos like <a><sup>text</sup></a> can cause the target to be the sup tag and not the anchor
targetElem = e.target.parentNode; targetElem = e.target.parentNode;
} }
if (!targetElem.attributes.hasOwnProperty('kavita-page')) { return; } if (!targetElem.attributes.hasOwnProperty('kavita-page')) { return; }
const page = parseInt(targetElem.attributes['kavita-page'].value, 10); const page = parseInt(targetElem.attributes['kavita-page'].value, 10);
if (this.adhocPageHistory.peek()?.page !== this.pageNum()) { if (this.adhocPageHistory.peek()?.page !== this.pageNum()) {
this.adhocPageHistory.push({page: this.pageNum(), scrollPart: this.readerService.scopeBookReaderXpath(this.lastSeenScrollPartPath)}); this.adhocPageHistory.push({page: this.pageNum(), scrollPart: this.readerService.scopeBookReaderXpath(this.lastSeenScrollPartPath)});
} }
const partValue = targetElem.attributes.hasOwnProperty('kavita-part') ? targetElem.attributes['kavita-part'].value : undefined; const partValue = targetElem.attributes.hasOwnProperty('kavita-part') ? targetElem.attributes['kavita-part'].value : undefined;
if (partValue && page === this.pageNum()) { if (partValue && page === this.pageNum()) {
this.scrollTo(targetElem.attributes['kavita-part'].value); this.scrollTo(targetElem.attributes['kavita-part'].value);
return; return;
} }
this.setPageNum(page); this.setPageNum(page);
this.loadPage(partValue); this.loadPage(partValue);
});
}); });
});
} }
moveFocus() { moveFocus() {
@ -1159,39 +1179,41 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
loadPage(part?: string | undefined, scrollTop?: number | undefined) { loadPage(part?: string | undefined, scrollTop?: number | undefined) {
console.log('load page called with: part: ', part, 'scrollTop: ', scrollTop);
this.isLoading.set(true); this.isLoading.set(true);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.bookService.getBookPage(this.chapterId, this.pageNum()).subscribe(content => { this.bookService.getBookPage(this.chapterId, this.pageNum()).subscribe(content => {
this.isSingleImagePage = this.checkSingleImagePage(content) // This needs be performed before we set this.page to avoid image jumping this.isSingleImagePage = this.checkSingleImagePage(content); // This needs be performed before we set this.page to avoid image jumping
this.updateSingleImagePageStyles(); this.updateSingleImagePageStyles();
this.page.set(this.domSanitizer.bypassSecurityTrustHtml(content)); this.page.set(this.domSanitizer.bypassSecurityTrustHtml(content));
this.scrollService.unlock(); this.scrollService.unlock();
this.setupObservers();
this.cdRef.markForCheck(); afterFrame(() => {
setTimeout(() => {
this.addLinkClickHandlers(); this.addLinkClickHandlers();
this.applyPageStyles(this.pageStyles()); this.applyPageStyles(this.pageStyles());
const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img'); const imgs = this.readingSectionElemRef.nativeElement.querySelectorAll('img');
if (imgs === null || imgs.length === 0) { if (imgs !== null && imgs.length > 0) {
Promise.all(Array.from(imgs ?? [])
.filter(img => !img.complete)
.map(img => new Promise(resolve => { img.onload = img.onerror = resolve; })))
.then(() => {
this.setupPage(part, scrollTop);
this.updateImageSizes();
this.injectImageBookmarkIndicators();
});
} else {
this.setupPage(part, scrollTop); this.setupPage(part, scrollTop);
return;
} }
Promise.all(Array.from(imgs)
.filter(img => !img.complete)
.map(img => new Promise(resolve => { img.onload = img.onerror = resolve; })))
.then(() => {
this.setupPage(part, scrollTop);
this.updateImageSizes();
this.injectImageBookmarkIndicators();
this.setupObservers();
});
this.firstLoad = false; this.firstLoad = false;
}, SCROLL_DELAY); });
}); });
} }
@ -1367,7 +1389,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
try { try {
this.setupPageScroll(part, scrollTop); this.scrollWithinPage(part, scrollTop);
} catch (ex) { } catch (ex) {
console.error(ex); console.error(ex);
} }
@ -1383,14 +1405,34 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
private setupPageScroll(part?: string | undefined, scrollTop?: number) { private scroll(lambda: () => void) {
afterFrame(() => {
setTimeout(lambda, SCROLL_DELAY)
});
}
private scrollWithinPage(part?: string | undefined, scrollTop?: number) {
if (part !== undefined && part !== '') { if (part !== undefined && part !== '') {
this.scrollTo(this.readerService.scopeBookReaderXpath(part));
console.log('Scrolling via part: ', part);
this.scroll(() => this.scrollTo(this.readerService.scopeBookReaderXpath(part)));
// afterFrame(() => {
// setTimeout(() => this.scrollTo(this.readerService.scopeBookReaderXpath(part)), SCROLL_DELAY)
// })
//
// setTimeout(() => {
// afterFrame(() => this.scrollTo(this.readerService.scopeBookReaderXpath(part)));
// }, SCROLL_DELAY);
return; return;
} }
if (scrollTop !== undefined && scrollTop !== 0) { if (scrollTop !== undefined && scrollTop !== 0) {
setTimeout(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement)); // setTimeout(() => {
// afterFrame(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement));
// }, SCROLL_DELAY);
console.log('Scrolling via scrollTop: ', scrollTop);
this.scroll(() => this.scrollService.scrollTo(scrollTop, this.reader.nativeElement));
return; return;
} }
@ -1399,31 +1441,57 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (layoutMode === BookPageLayoutMode.Default) { if (layoutMode === BookPageLayoutMode.Default) {
if (writingStyle === WritingStyle.Vertical) { if (writingStyle === WritingStyle.Vertical) {
setTimeout(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement), SCROLL_DELAY); console.log('Scrolling via x axis: ', this.bookContentElemRef.nativeElement.clientWidth, ' via ', this.reader.nativeElement);
this.scroll(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement));
//
// setTimeout(() => {
// afterFrame(()=> this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.clientWidth, this.reader.nativeElement));
// }, SCROLL_DELAY);
return; return;
} }
setTimeout(() => this.scrollService.scrollTo(0, this.reader.nativeElement), SCROLL_DELAY); // setTimeout(() => {
// afterFrame(() => this.scrollService.scrollTo(0, this.reader.nativeElement));
// }, SCROLL_DELAY);
console.log('Scrolling via x axis to 0: ', 0, ' via ', this.reader.nativeElement);
this.scroll(() => this.scrollService.scrollToX(0, this.reader.nativeElement));
return; return;
} }
if (writingStyle === WritingStyle.Vertical) { if (writingStyle === WritingStyle.Vertical) {
if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) {
setTimeout(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto'), SCROLL_DELAY); // setTimeout(() => {
// afterFrame(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto'));
// }, SCROLL_DELAY);
console.log('(Vertical) Scrolling via x axis to: ', this.bookContentElemRef.nativeElement.scrollHeight, ' via ', this.bookContentElemRef.nativeElement);
this.scroll(() => this.scrollService.scrollTo(this.bookContentElemRef.nativeElement.scrollHeight, this.bookContentElemRef.nativeElement, 'auto'));
return; return;
} }
setTimeout(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement,'auto' ), SCROLL_DELAY); // setTimeout(() => {
// afterFrame(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement, 'auto'));
// }, SCROLL_DELAY);
console.log('(Vertical) Scrolling via x axis to 0: ', 0, ' via ', this.bookContentElemRef.nativeElement);
this.scroll(() => this.scrollService.scrollTo(0, this.bookContentElemRef.nativeElement, 'auto'));
return; return;
} }
// We need to check if we are paging back, because we need to adjust the scroll // We need to check if we are paging back, because we need to adjust the scroll
if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) { if (this.pagingDirection === PAGING_DIRECTION.BACKWARDS) {
setTimeout(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement), SCROLL_DELAY); // setTimeout(() => {
// afterFrame(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement));
// }, SCROLL_DELAY);
console.log('(Page Back) Scrolling via x axis to: ', this.bookContentElemRef.nativeElement.scrollWidth, ' via ', this.bookContentElemRef.nativeElement);
this.scroll(() => this.scrollService.scrollToX(this.bookContentElemRef.nativeElement.scrollWidth, this.bookContentElemRef.nativeElement));
return; return;
} }
setTimeout(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement), SCROLL_DELAY); setTimeout(() => {
afterFrame(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement));
}, SCROLL_DELAY);
console.log('Scrolling via x axis to 0: ', 0, ' via ', this.bookContentElemRef.nativeElement);
this.scroll(() => this.scrollService.scrollToX(0, this.bookContentElemRef.nativeElement));
} }
private setupAnnotationElements() { private setupAnnotationElements() {
@ -1436,11 +1504,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
const [,, pageWidth] = this.getVirtualPage(); const pageSize = this.pageSize();
const actualWidth = this.bookContentElemRef.nativeElement.scrollWidth; const [_, totalScroll] = this.getScrollOffsetAndTotalScroll();
const lastPageWidth = actualWidth % pageWidth; const lastPageSize = totalScroll % pageSize;
if (lastPageWidth >= pageWidth / 2 || lastPageWidth === 0) { if (lastPageSize >= pageSize / 2 || lastPageSize === 0) {
// The last page needs more than one column, no pages will be duplicated // The last page needs more than one column, no pages will be duplicated
return; return;
} }
@ -1595,7 +1663,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.readingSectionElemRef == null) return 0; if (this.readingSectionElemRef == null) return 0;
const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2); const margin = (this.convertVwToPx(parseInt(marginLeft, 10)) * 2);
return this.readingSectionElemRef.nativeElement.clientWidth - margin + (COLUMN_GAP * columnGapModifier);
// console.log('page size calc, client width: ', this.readingSectionElemRef.nativeElement.clientWidth)
// console.log('page size calc, margin: ', margin)
// console.log('page size calc, col gap: ', ((COLUMN_GAP / 2) * columnGapModifier));
// console.log("clientWidth", this.readingSectionElemRef.nativeElement.clientWidth, "window", window.innerWidth, "margin", margin, "left", marginLeft)
return this.readingSectionElemRef.nativeElement.clientWidth - margin + ((COLUMN_GAP) * columnGapModifier);
}); });
pageHeight = computed(() => { pageHeight = computed(() => {
@ -1663,9 +1736,13 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
pageSize = computed(() => { pageSize = computed(() => {
return this.writingStyle() === WritingStyle.Vertical const height = this.pageHeight();
? this.pageHeight() const width = this.pageWidth();
: this.pageWidth(); const writingStyle = this.writingStyle();
return writingStyle === WritingStyle.Vertical
? height
: width;
}); });
@ -1772,10 +1849,20 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
return; return;
} }
if (pageLevelStyles.includes(item[0])) { if (pageLevelStyles.includes(item[0])) {
this.renderer.setStyle(this.bookContentElemRef.nativeElement, item[0], item[1], RendererStyleFlags2.Important);
let value = item[1];
// Convert vw for margin into fixed pixels otherwise when paging, 2 column mode will bleed text between columns
if (item[0].startsWith('margin')) {
const vw = parseInt(item[1].replace('vw', ''), 10);
value = `${this.convertVwToPx(vw)}px`;
}
this.renderer.setStyle(this.bookContentElemRef.nativeElement, item[0], value, RendererStyleFlags2.Important);
} }
}); });
const individualElementStyles = Object.entries(pageStyles).filter(item => elementLevelStyles.includes(item[0])); const individualElementStyles = Object.entries(pageStyles).filter(item => elementLevelStyles.includes(item[0]));
for(let i = 0; i < this.bookContentElemRef.nativeElement.children.length; i++) { for(let i = 0; i < this.bookContentElemRef.nativeElement.children.length; i++) {
const elem = this.bookContentElemRef.nativeElement.children.item(i); const elem = this.bookContentElemRef.nativeElement.children.item(i);
@ -1892,7 +1979,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
const writingStyle = this.writingStyle(); const writingStyle = this.writingStyle();
if (layout !== BookPageLayoutMode.Default) { if (layout !== BookPageLayoutMode.Default) {
setTimeout(() => this.scrollService.scrollIntoView(element as HTMLElement, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}})); afterFrame(() => this.scrollService.scrollIntoView(element as HTMLElement, {timeout, scrollIntoViewOptions: {'block': 'start', 'inline': 'start'}}));
return; return;
} }
@ -1900,12 +1987,12 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
case WritingStyle.Vertical: case WritingStyle.Vertical:
const windowWidth = window.innerWidth || document.documentElement.clientWidth; const windowWidth = window.innerWidth || document.documentElement.clientWidth;
const scrollLeft = element.getBoundingClientRect().left + window.scrollX - (windowWidth - element.getBoundingClientRect().width); const scrollLeft = element.getBoundingClientRect().left + window.scrollX - (windowWidth - element.getBoundingClientRect().width);
setTimeout(() => this.scrollService.scrollToX(scrollLeft, this.reader.nativeElement, 'smooth'), SCROLL_DELAY); afterFrame(() => this.scrollService.scrollToX(scrollLeft, this.reader.nativeElement, 'smooth'));
break; break;
case WritingStyle.Horizontal: case WritingStyle.Horizontal:
const fromTopOffset = element.getBoundingClientRect().top + window.scrollY + TOP_OFFSET; const fromTopOffset = element.getBoundingClientRect().top + window.scrollY + TOP_OFFSET;
// We need to use a delay as webkit browsers (aka Apple devices) don't always have the document rendered by this point // We need to use a delay as webkit browsers (aka Apple devices) don't always have the document rendered by this point
setTimeout(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement), SCROLL_DELAY); afterFrame(() => this.scrollService.scrollTo(fromTopOffset, this.reader.nativeElement));
} }
} }
@ -1983,7 +2070,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
this.updateImageSizes() this.updateImageSizes()
}, 200); }, 200);
this.updateSingleImagePageStyles() this.updateSingleImagePageStyles();
// Calculate if bottom actionbar is needed. On a timeout to get accurate heights // Calculate if bottom actionbar is needed. On a timeout to get accurate heights
// if (this.bookContentElemRef == null) { // if (this.bookContentElemRef == null) {
@ -2108,7 +2195,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
} }
handleReaderClick(event: MouseEvent) { handleReaderClick(event: MouseEvent) {
if (!this.clickToPaginate()) { if (!this.clickToPaginate() && !this.immersiveMode()) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.toggleMenu(event); this.toggleMenu(event);
@ -2176,8 +2263,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
viewAnnotations() { async viewAnnotations() {
this.epubMenuService.openViewAnnotationsDrawer((annotation: Annotation) => { await this.epubMenuService.openViewAnnotationsDrawer((annotation: Annotation) => {
if (this.pageNum() != annotation.pageNumber) { if (this.pageNum() != annotation.pageNumber) {
this.setPageNum(annotation.pageNumber); this.setPageNum(annotation.pageNumber);
} }
@ -2197,27 +2284,93 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
private debugVirtualPaging() { /**
if (this.layoutMode() === BookPageLayoutMode.Default) return; * With queries and pure math, determines the actual viewport the user can see.
*
* NOTE: On Scroll LayoutMode, the height/bottom are not correct
*/
getViewportBoundingRect() {
const margin = this.getMargin();
const [currentVirtualPage, _, pageSize] = this.getVirtualPage();
const visibleBoundingBox = this.bookContentElemRef.nativeElement.getBoundingClientRect();
const [scrollOffset, totalScroll] = this.getScrollOffsetAndTotalScroll(); let bookContentPadding = 20;
const pageSize = this.pageSize(); let bookPadding = getComputedStyle(this.bookContentElemRef?.nativeElement!).paddingTop;
const [currentVirtualPage, totalVirtualPages] = this.getVirtualPage(); if (bookPadding) {
bookContentPadding = parseInt(bookPadding.toString().replace('px', ''), 10);
}
console.log('Virtual Paging Debug:', { // Adjust the bounding box for what is actually visible
scrollOffset, const bottomBarHeight = this.document.querySelector('.bottom-bar')?.getBoundingClientRect().height ?? 38;
totalScroll, const topBarHeight = this.document.querySelector('.fixed-top')?.getBoundingClientRect().height ?? 48;
pageSize,
currentVirtualPage, // console.log('bottom: ', visibleBoundingBox.bottom) // TODO: Bottom isn't ideal in scroll mode
totalVirtualPages,
layoutMode: this.layoutMode(), const left = margin;
writingStyle: this.writingStyle(), const top = topBarHeight;
bookContentWidth: this.bookContentElemRef?.nativeElement?.clientWidth, const bottom = visibleBoundingBox.bottom - bottomBarHeight + bookContentPadding; // bookContent has a 20px padding top/bottom
bookContentHeight: this.bookContentElemRef?.nativeElement?.clientHeight, const width = pageSize;
scrollWidth: this.bookContentElemRef?.nativeElement?.scrollWidth, const height = bottom - top;
scrollHeight: this.bookContentElemRef?.nativeElement?.scrollHeight const right = left + width;
console.log('Visible Viewport', {
left, right, top, bottom, width, height
}); });
return {
left, right, top, bottom, width, height
}
} }
debugInsertViewportView() {
const viewport = this.getViewportBoundingRect();
// Insert a debug element to help visualize
document.querySelector('#test')?.remove();
// Create and inject the red rectangle div
const redRect = document.createElement('div');
redRect.id = 'test';
redRect.style.position = 'absolute';
redRect.style.left = `${viewport.left}px`;
redRect.style.top = `${viewport.top}px`;
redRect.style.width = `${viewport.width}px`;
redRect.style.height = `${viewport.height}px`;
redRect.style.border = '5px solid red';
redRect.style.pointerEvents = 'none';
redRect.style.zIndex = '1000';
// Inject into the document
document.body.appendChild(redRect);
}
/**
* Get actual px margin (just one side), falls back to vw -> px mapping calculation
*/
getMargin() {
const pageStyles = this.pageStyles();
let usedComputed = false;
let margin = this.convertVwToPx(parseInt(pageStyles['margin-left'], 10));
const computedMargin = getComputedStyle(this.bookContentElemRef?.nativeElement!).marginLeft;
if (computedMargin) {
margin = parseInt(computedMargin.toString().replace('px', ''), 10);
usedComputed = true;
}
// Sometimes computed will be 0 when first loading which can cause issues (first load)
if (usedComputed && margin < this.convertVwToPx(parseInt(pageStyles['margin-left'], 10))) {
console.warn('Computed margin was 0px when we expected non-zero. Defaulted back to derived vw->px value');
return this.convertVwToPx(parseInt(pageStyles['margin-left'], 10));
}
return margin;
}
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
protected readonly environment = environment;
} }

View File

@ -3,22 +3,28 @@
@let bookChapters = chapters(); @let bookChapters = chapters();
@if (bookChapters.length === 0) { @if (bookChapters.length === 0) {
<div>
<em>{{t('no-data')}}</em> @if (loading()) {
</div> <app-loading [loading]="true"></app-loading>
} @else if (bookChapters.length === 1) { } @else {
<div>
<em>{{t('no-data')}}</em>
</div>
}
} @else if (isDisplayingChildrenOnly()) {
<div> <div>
<ul> <ul>
@for(chapter of bookChapters[0].children; track chapter.title) { @for(chapter of displayedChapters(); track chapter.title) {
<li class="{{chapter.page === pageNum() ? 'active': ''}}"> <li [id]="`${$index}`" class="{{isChapterSelected(chapter) ? 'active': ''}}">
<a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a> <a href="javascript:void(0);" (click)="loadChapterPage(chapter.page, chapter.part)">{{chapter.title}}</a>
</li> </li>
} }
</ul> </ul>
</div> </div>
} @else { } @else {
@for (chapterGroup of bookChapters; track chapterGroup.title + chapterGroup.children.length) { @for (chapterGroup of displayedChapters(); track chapterGroup.title + chapterGroup.children.length) {
<ul class="chapter-title"> <ul [id]="`${$index}`" class="chapter-title">
<li class="{{isChapterSelected(chapterGroup) ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')"> <li class="{{isChapterSelected(chapterGroup) ? 'active': ''}}" (click)="loadChapterPage(chapterGroup.page, '')">
{{chapterGroup.title}} {{chapterGroup.title}}
</li> </li>

View File

@ -2,32 +2,62 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed, effect,
EventEmitter, EventEmitter,
inject, inject,
model, model, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {BookChapterItem} from '../../_models/book-chapter-item'; import {BookChapterItem} from '../../_models/book-chapter-item';
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {LoadingComponent} from "../../../shared/loading/loading.component";
import {DOCUMENT} from "@angular/common";
@Component({ @Component({
selector: 'app-table-of-contents', selector: 'app-table-of-contents',
templateUrl: './table-of-contents.component.html', templateUrl: './table-of-contents.component.html',
styleUrls: ['./table-of-contents.component.scss'], styleUrls: ['./table-of-contents.component.scss'],
imports: [TranslocoDirective], imports: [TranslocoDirective, LoadingComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class TableOfContentsComponent { export class TableOfContentsComponent {
private readonly cdRef = inject(ChangeDetectorRef); private readonly document = inject(DOCUMENT)
chapterId = model.required<number>(); chapterId = model.required<number>();
pageNum = model.required<number>(); pageNum = model.required<number>();
currentPageAnchor = model<string>(); currentPageAnchor = model<string>();
chapters = model.required<Array<BookChapterItem>>(); chapters = model.required<Array<BookChapterItem>>();
loading = model.required<boolean>();
displayedChapters = computed(() => {
const chapters = this.chapters();
if (chapters.length === 1) {
return chapters[0].children;
}
return chapters;
});
isDisplayingChildrenOnly = computed(() => this.chapters().length === 1);
@Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter();
constructor() {
effect(() => {
const selectedChapterIdx = this.displayedChapters()
.findIndex(chapter => this.isChapterSelected(chapter));
if (selectedChapterIdx < 0) return;
setTimeout(() => {
const chapterElement = this.document.getElementById(`${selectedChapterIdx}`);
if (chapterElement) {
chapterElement.scrollIntoView({behavior: 'smooth'});
}
}, 10); // Some delay to allow the items to be rendered into the DOM
});
}
cleanIdSelector(id: string) { cleanIdSelector(id: string) {
const tokens = id.split('/'); const tokens = id.split('/');
@ -46,7 +76,7 @@ export class TableOfContentsComponent {
isChapterSelected(chapterGroup: BookChapterItem) { isChapterSelected(chapterGroup: BookChapterItem) {
const currentPageNum = this.pageNum(); const currentPageNum = this.pageNum();
const chapters = this.chapters(); const chapters = this.displayedChapters();
if (chapterGroup.page === currentPageNum) { if (chapterGroup.page === currentPageNum) {
return true; return true;

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'bookmarks'"> <ng-container *transloco="let t; prefix: 'bookmarks'">
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h4 title> <h4 title>
{{t('title')}} {{t('title')}}

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-genres'" > <ng-container *transloco="let t; prefix:'browse-genres'" >
<app-side-nav-companion-bar [hasFilter]="false"> <app-side-nav-companion-bar [hasFilter]="false">
<h2 title> <h2 title>
<span>{{t('title')}}</span> <span>{{t('title')}}</span>

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-people'" > <ng-container *transloco="let t; prefix:'browse-people'" >
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h2 title> <h2 title>
<span>{{t('title')}}</span> <span>{{t('title')}}</span>

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read:'browse-tags'" > <ng-container *transloco="let t; prefix:'browse-tags'" >
<app-side-nav-companion-bar [hasFilter]="false"> <app-side-nav-companion-bar [hasFilter]="false">
<h2 title> <h2 title>
<span>{{t('title')}}</span> <span>{{t('title')}}</span>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'bulk-add-to-collection'"> <ng-container *transloco="let t; prefix: 'bulk-add-to-collection'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'edit-collection-tags'"> <ng-container *transloco="let t; prefix: 'edit-collection-tags'">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title', {collectionName: tag.title})}}</h4> <h4 class="modal-title" id="modal-basic-title">{{t('title', {collectionName: tag.title})}}</h4>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'edit-series-modal'"> <ng-container *transloco="let t; prefix: 'edit-series-modal'">
@if (series) { @if (series) {
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'bulk-operations'"> <ng-container *transloco="let t; prefix: 'bulk-operations'">
@if (bulkSelectionService.selections$ | async; as selectionCount) { @if (bulkSelectionService.selections$ | async; as selectionCount) {
@if (selectionCount > 0) { @if (selectionCount > 0) {
<div class="bulk-select-container" [ngStyle]="{'margin-left': marginLeft + 'px', 'margin-right': marginRight + 'px'}"> <div class="bulk-select-container" [ngStyle]="{'margin-left': marginLeft + 'px', 'margin-right': marginRight + 'px'}">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'card-detail-layout'"> <ng-container *transloco="let t; prefix: 'card-detail-layout'">
<app-loading [loading]="isLoading"></app-loading> <app-loading [loading]="isLoading"></app-loading>
@if (header().length > 0) { @if (header().length > 0) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'card-item'"> <ng-container *transloco="let t; prefix: 'card-item'">
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}"> <div class="card-item-container card {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick($event)"> <div class="overlay" (click)="handleClick($event)">
@if (total > 0 || suppressArchiveWarning) { @if (total > 0 || suppressArchiveWarning) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'card-item'"> <ng-container *transloco="let t; prefix: 'card-item'">
<div class="card-item-container card position-relative {{selected ? 'selected-highlight' : ''}}" > <div class="card-item-container card position-relative {{selected ? 'selected-highlight' : ''}}" >
<div class="overlay" (click)="handleClick($event)"> <div class="overlay" (click)="handleClick($event)">
@if (chapter.pages > 0 || suppressArchiveWarning) { @if (chapter.pages > 0 || suppressArchiveWarning) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'cover-image-chooser'"> <ng-container *transloco="let t; prefix: 'cover-image-chooser'">
<div class="container-fluid" style="padding-left: 0; padding-right: 0"> <div class="container-fluid" style="padding-left: 0; padding-right: 0">
<form [formGroup]="form"> <form [formGroup]="form">
<ngx-file-drop (onFileDrop)="dropped($event)" <ngx-file-drop (onFileDrop)="dropped($event)"

View File

@ -4,7 +4,6 @@ import {
Component, Component,
EventEmitter, EventEmitter,
inject, inject,
Inject,
Input, Input,
OnInit, OnInit,
Output Output
@ -43,6 +42,7 @@ export class CoverImageChooserComponent implements OnInit {
public readonly toastr = inject(ToastrService); public readonly toastr = inject(ToastrService);
public readonly uploadService = inject(UploadService); public readonly uploadService = inject(UploadService);
private readonly colorscapeService = inject(ColorscapeService) private readonly colorscapeService = inject(ColorscapeService)
private readonly document = inject(DOCUMENT)
/** /**
* If buttons show under images to allow immediate selection of cover images. * If buttons show under images to allow immediate selection of cover images.
@ -87,8 +87,6 @@ export class CoverImageChooserComponent implements OnInit {
acceptableExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].join(','); acceptableExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].join(',');
mode: 'file' | 'url' | 'all' = 'all'; mode: 'file' | 'url' | 'all' = 'all';
constructor(@Inject(DOCUMENT) private document: Document) { }
ngOnInit(): void { ngOnInit(): void {
this.form = this.fb.group({ this.form = this.fb.group({
coverImageUrl: new FormControl('', []) coverImageUrl: new FormControl('', [])

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'download-indicator'"> <ng-container *transloco="let t; prefix: 'download-indicator'">
@if (download$ | async; as download) { @if (download$ | async; as download) {
<span class="download"> <span class="download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader> <app-circular-loader [currentValue]="download.progress"></app-circular-loader>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'edit-chapter-progress'"> <ng-container *transloco="let t; prefix: 'edit-chapter-progress'">
<!-- <ngx-datatable--> <!-- <ngx-datatable-->
<!-- class="bootstrap"--> <!-- class="bootstrap"-->
<!-- [rows]="items.controls"--> <!-- [rows]="items.controls"-->

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'edit-series-relation'"> <ng-container *transloco="let t; prefix: 'edit-series-relation'">
<div class="container-fluid"> <div class="container-fluid">
<p> <p>

View File

@ -1,3 +1,3 @@
<ng-container *transloco="let t; read: 'entity-title'"> <ng-container *transloco="let t; prefix: 'entity-title'">
{{renderText | defaultValue}} {{renderText | defaultValue}}
</ng-container> </ng-container>

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'external-series-card'"> <ng-container *transloco="let t; prefix: 'external-series-card'">
@if (data !== undefined) { @if (data !== undefined) {
<div class="card-item-container card clickable position-relative"> <div class="card-item-container card clickable position-relative">
<div class="overlay" (click)="handleClick()"> <div class="overlay" (click)="handleClick()">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'next-expected-card'"> <ng-container *transloco="let t; prefix: 'next-expected-card'">
<div class="card-item-container card"> <div class="card-item-container card">
<div class="overlay"> <div class="overlay">
<app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="232.91px" width="160px" classes="extreme-blur" <app-image [styles]="{'border-radius': '.25rem .25rem 0 0'}" height="232.91px" width="160px" classes="extreme-blur"

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'card-item'"> <ng-container *transloco="let t; prefix: 'card-item'">
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}"> <div class="card-item-container card {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick($event)"> <div class="overlay" (click)="handleClick($event)">
@if(entity.coverImage) { @if(entity.coverImage) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'card-item'"> <ng-container *transloco="let t; prefix: 'card-item'">
<div class="card-item-container card position-relative {{selected ? 'selected-highlight' : ''}}"> <div class="card-item-container card position-relative {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick()"> <div class="overlay" (click)="handleClick()">
@if (series.pages > 0) { @if (series.pages > 0) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'card-item'"> <ng-container *transloco="let t; prefix: 'card-item'">
<div class="card-item-container card {{selected ? 'selected-highlight' : ''}}" > <div class="card-item-container card {{selected ? 'selected-highlight' : ''}}" >
<div class="overlay position-relative" (click)="handleClick($event)"> <div class="overlay position-relative" (click)="handleClick($event)">
@if (volume.pages > 0 || suppressArchiveWarning) { @if (volume.pages > 0 || suppressArchiveWarning) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'carousel-reel'"> <ng-container *transloco="let t; prefix: 'carousel-reel'">
@if (alwaysShow || items && items.length > 0) { @if (alwaysShow || items && items.length > 0) {
<div class="carousel-container mb-3"> <div class="carousel-container mb-3">

View File

@ -47,7 +47,7 @@
} }
.title-icon { .title-icon {
color: var(--body-text-color); color: var(--body-text-color);
font-size: 0.9rem; font-size: 0.9rem;
cursor: pointer; cursor: pointer;
} }

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'series-detail'"> <ng-container *transloco="let t; prefix: 'series-detail'">
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock> <div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid" #scrollingBlock>

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'all-collections'"> <ng-container *transloco="let t; prefix: 'all-collections'">
<app-side-nav-companion-bar [hasFilter]="false" (filterOpen)="filterOpen.emit($event)"> <app-side-nav-companion-bar [hasFilter]="false" (filterOpen)="filterOpen.emit($event)">
<h4 title>{{t('title')}}</h4> <h4 title>{{t('title')}}</h4>
<h5 subtitle>{{t('item-count', {num: collections.length | number})}}</h5> <h5 subtitle>{{t('item-count', {num: collections.length | number})}}</h5>

View File

@ -1,5 +1,5 @@
<div class="main-container container-fluid"> <div class="main-container container-fluid">
<ng-container *transloco="let t; read: 'collection-detail'"> <ng-container *transloco="let t; prefix: 'collection-detail'">
<div #companionBar> <div #companionBar>
@if (series) { @if (series) {
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'collection-owner'"> <ng-container *transloco="let t; prefix: 'collection-owner'">
@if (accountService.currentUser$ | async; as user) { @if (accountService.currentUser$ | async; as user) {
<div class="fw-light text-accent"> <div class="fw-light text-accent">
{{t('collection-created-label', {owner: collection.owner})}} {{t('collection-created-label', {owner: collection.owner})}}

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'import-mal-collection-modal'"> <ng-container *transloco="let t; prefix: 'import-mal-collection-modal'">
<p>{{t('description')}}</p> <p>{{t('description')}}</p>
<ul> <ul>

View File

@ -1,7 +1,7 @@
<div class="main-container"> <div class="main-container">
<app-side-nav-companion-bar></app-side-nav-companion-bar> <app-side-nav-companion-bar></app-side-nav-companion-bar>
<ng-container *transloco="let t; read: 'dashboard'"> <ng-container *transloco="let t; prefix: 'dashboard'">
@if (libraries$ | async; as libraries) { @if (libraries$ | async; as libraries) {
@if (libraries.length === 0) { @if (libraries.length === 0) {
@if (accountService.isAdmin$ | async; as isAdmin) { @if (accountService.isAdmin$ | async; as isAdmin) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'infinite-scroller'"> <ng-container *transloco="let t; prefix: 'infinite-scroller'">
@if (showDebugBar()) { @if (showDebugBar()) {
<div class="fixed-top overlay"> <div class="fixed-top overlay">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'manga-reader'"> <ng-container *transloco="let t; prefix: 'manga-reader'">
<div class="reader" #reader [ngStyle]="{overflow: (isFullscreen ? 'auto' : 'visible')}"> <div class="reader" #reader [ngStyle]="{overflow: (isFullscreen ? 'auto' : 'visible')}">
@if(debugMode) { @if(debugMode) {
<div class="fixed-top overlay"> <div class="fixed-top overlay">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'metadata-builder'"> <ng-container *transloco="let t; prefix: 'metadata-builder'">
@if (filter) { @if (filter) {
<form [formGroup]="formGroup"> <form [formGroup]="formGroup">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'metadata-filter-row'"> <ng-container *transloco="let t; prefix: 'metadata-filter-row'">
<form [formGroup]="formGroup"> <form [formGroup]="formGroup">
<div class="row g-0"> <div class="row g-0">
<div class="col-md-3 me-2 col-10 mb-2"> <div class="col-md-3 me-2 col-10 mb-2">

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'metadata-filter'"> <ng-container *transloco="let t; prefix: 'metadata-filter'">
@if (toggleService.toggleState$ | async; as isOpen) { @if (toggleService.toggleState$ | async; as isOpen) {
@if (utilityService.getActiveBreakpoint(); as activeBreakpoint) { @if (utilityService.getActiveBreakpoint(); as activeBreakpoint) {
@if (activeBreakpoint >= Breakpoint.Tablet) { @if (activeBreakpoint >= Breakpoint.Tablet) {

View File

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read: 'events-widget'"> <ng-container *transloco="let t; prefix: 'events-widget'">
@if (accountService.isAdmin$ | async) { @if (accountService.isAdmin$ | async) {
@if (downloadService.activeDownloads$ | async; as activeDownloads) { @if (downloadService.activeDownloads$ | async; as activeDownloads) {

Some files were not shown because too many files have changed in this diff Show More