Event Widget Updates + Format Downloads + Scanner Work (#3024)
This commit is contained in:
parent
30a8a2555f
commit
a427d02ed1
34 changed files with 971 additions and 694 deletions
|
@ -1,9 +1,9 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {Inject, inject, Injectable} from '@angular/core';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
|
||||
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe';
|
||||
import { map } from 'rxjs';
|
||||
import {asyncScheduler, finalize, map, tap} from 'rxjs';
|
||||
import { MangaFormatPipe } from '../_pipes/manga-format.pipe';
|
||||
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
|
||||
import { TopUserRead } from '../statistics/_models/top-reads';
|
||||
|
@ -15,6 +15,10 @@ import { MangaFormat } from '../_models/manga-format';
|
|||
import { TextResonse } from '../_types/text-response';
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
||||
import {throttleTime} from "rxjs/operators";
|
||||
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
|
||||
import {download} from "../shared/_models/download";
|
||||
import {Saver, SAVER} from "../_providers/saver.provider";
|
||||
|
||||
export enum DayOfWeek
|
||||
{
|
||||
|
@ -37,7 +41,7 @@ export class StatisticsService {
|
|||
publicationStatusPipe = new PublicationStatusPipe(this.translocoService);
|
||||
mangaFormatPipe = new MangaFormatPipe(this.translocoService);
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
||||
|
||||
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
||||
// TODO: Convert to httpParams object
|
||||
|
@ -109,6 +113,20 @@ export class StatisticsService {
|
|||
return this.httpClient.get<FileExtensionBreakdown>(this.baseUrl + 'stats/server/file-breakdown');
|
||||
}
|
||||
|
||||
downloadFileBreakdown(extension: string) {
|
||||
return this.httpClient.get(this.baseUrl + 'stats/server/file-extension?fileExtension=' + encodeURIComponent(extension),
|
||||
{observe: 'events', responseType: 'blob', reportProgress: true}
|
||||
).pipe(
|
||||
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
|
||||
download((blob, filename) => {
|
||||
this.save(blob, decodeURIComponent(filename));
|
||||
}),
|
||||
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)),
|
||||
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
getReadCountByDay(userId: number = 0, days: number = 0) {
|
||||
return this.httpClient.get<Array<any>>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
import {ChangeDetectorRef, Directive, EventEmitter, inject, Input, OnInit, Output} from "@angular/core";
|
||||
|
||||
export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0);
|
||||
export type SortColumn<T> = keyof T | '';
|
||||
|
@ -11,6 +11,7 @@ export interface SortEvent<T> {
|
|||
}
|
||||
|
||||
@Directive({
|
||||
// eslint-disable-next-line @angular-eslint/directive-selector
|
||||
selector: 'th[sortable]',
|
||||
host: {
|
||||
'[class.asc]': 'direction === "asc"',
|
||||
|
@ -29,4 +30,4 @@ export class SortableHeader<T> {
|
|||
this.direction = rotate[this.direction];
|
||||
this.sort.emit({ column: this.sortable, direction: this.direction });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,27 +4,33 @@
|
|||
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
<span>{{libraryName}}</span>
|
||||
</h2>
|
||||
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
@if (active.fragment === '') {
|
||||
<h6 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h6>
|
||||
}
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-loading [absolute]="true" [loading]="bulkLoader"></app-loading>
|
||||
<app-card-detail-layout *ngIf="filter"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[refresh]="refresh"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
@if (filter) {
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[trackByIdentity]="trackByIdentity"
|
||||
[filterOpen]="filterOpen"
|
||||
[jumpBarKeys]="jumpKeys"
|
||||
[refresh]="refresh"
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"
|
||||
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
}
|
||||
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -33,18 +33,18 @@ import {SentenceCasePipe} from '../_pipes/sentence-case.pipe';
|
|||
import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component';
|
||||
import {SeriesCardComponent} from '../cards/series-card/series-card.component';
|
||||
import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import {DecimalPipe, NgFor, NgIf} from '@angular/common';
|
||||
import {DecimalPipe} from '@angular/common';
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
|
||||
import {MetadataService} from "../_services/metadata.service";
|
||||
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
||||
import {FilterField} from "../_models/metadata/v2/filter-field";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
import {debounceTime, ReplaySubject, tap} from "rxjs";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-detail',
|
||||
|
@ -52,14 +52,25 @@ import {LoadingComponent} from "../shared/loading/loading.component";
|
|||
styleUrls: ['./library-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf
|
||||
, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent,
|
||||
CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent]
|
||||
})
|
||||
export class LibraryDetailComponent implements OnInit {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly libraryService = inject(LibraryService);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly hubService = inject(MessageHubService);
|
||||
private readonly utilityService = inject(UtilityService);
|
||||
private readonly filterUtilityService = inject(FilterUtilitiesService);
|
||||
public readonly navService = inject(NavService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
|
||||
libraryId!: number;
|
||||
libraryName = '';
|
||||
|
@ -82,6 +93,8 @@ export class LibraryDetailComponent implements OnInit {
|
|||
];
|
||||
active = this.tabs[0];
|
||||
|
||||
loadPageSource = new ReplaySubject(1);
|
||||
loadPage$ = this.loadPageSource.asObservable();
|
||||
|
||||
bulkActionCallback = async (action: ActionItem<any>, data: any) => {
|
||||
const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
@ -142,10 +155,8 @@ export class LibraryDetailComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
|
||||
|
||||
constructor() {
|
||||
const routeId = this.route.snapshot.paramMap.get('libraryId');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
|
@ -180,6 +191,8 @@ export class LibraryDetailComponent implements OnInit {
|
|||
|
||||
this.filterSettings.presetsV2 = this.filter;
|
||||
|
||||
this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe();
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -191,7 +204,7 @@ export class LibraryDetailComponent implements OnInit {
|
|||
const seriesAdded = event.payload as SeriesAddedEvent;
|
||||
if (seriesAdded.libraryId !== this.libraryId) return;
|
||||
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
|
||||
this.loadPage();
|
||||
this.loadPageSource.next(true);
|
||||
return;
|
||||
}
|
||||
this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => {
|
||||
|
@ -211,7 +224,7 @@ export class LibraryDetailComponent implements OnInit {
|
|||
const seriesRemoved = event.payload as SeriesRemovedEvent;
|
||||
if (seriesRemoved.libraryId !== this.libraryId) return;
|
||||
if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) {
|
||||
this.loadPage(); // TODO: This can be quite expensive when bulk deleting. We can refactor this to an ReplaySubject to debounce
|
||||
this.loadPageSource.next(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -286,12 +299,12 @@ export class LibraryDetailComponent implements OnInit {
|
|||
this.filter = data.filterV2;
|
||||
|
||||
if (data.isFirst) {
|
||||
this.loadPage();
|
||||
this.loadPageSource.next(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => {
|
||||
this.loadPage();
|
||||
this.loadPageSource.next(true);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,139 +1,118 @@
|
|||
<ng-container *transloco="let t; read: 'events-widget'">
|
||||
<ng-container *ngIf="isAdmin$ | async">
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngIf="infos$ | async as infos">
|
||||
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0, 'colored-error': errors.length > 0,
|
||||
'colored-info': infos.length > 0 && errors.length === 0}"
|
||||
[ngbPopover]="popContent" [title]="t('title-alt')" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
@if (isAdmin$ | async) {
|
||||
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
|
||||
@if (errors$ | async; as errors) {
|
||||
@if (infos$ | async; as infos) {
|
||||
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
|
||||
<button type="button" class="btn btn-icon"
|
||||
[ngbPopover]="popContent" [title]="t('title-alt')"
|
||||
placement="bottom" [popoverClass]="'nav-events'"
|
||||
[autoClose]="'outside'">
|
||||
|
||||
@if (onlineUsers.length > 1) {
|
||||
<span class="me-2" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}">{{onlineUsers.length}}</span>
|
||||
}
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}"></i>
|
||||
|
||||
|
||||
@if (errors.length > 0) {
|
||||
<i aria-hidden="true" class="fa fa-circle-exclamation nav widget-button--indicator error"></i>
|
||||
} @else if (infos.length > 0) {
|
||||
<i aria-hidden="true" class="fa fa-circle-info nav widget-button--indicator info"></i>
|
||||
} @else if (activeEvents > 0 || activeDownloads.length > 0) {
|
||||
<div class="nav widget-button--indicator spinner-border spinner-border-sm"></div>
|
||||
} @else if (updateAvailable) {
|
||||
<i aria-hidden="true" class="fa fa-circle-arrow-up nav widget-button--indicator update"></i>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<ng-template #popContent>
|
||||
|
||||
<ul class="list-group list-group-flush dark-menu">
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngIf="infos$ | async as infos">
|
||||
<li class="list-group-item dark-menu-item clickable" *ngIf="errors.length > 0 || infos.length > 0" (click)="clearAllErrorOrInfos()">
|
||||
{{t('dismiss-all')}}
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@if (debugMode) {
|
||||
<ng-container>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">Title goes here</div>
|
||||
<div class="accent-text mb-1">Subtitle goes here</div>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="progress" style="height: 5px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">Title goes here</div>
|
||||
<div class="accent-text mb-1">Subtitle goes here</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div>
|
||||
<div class="h6 mb-1">Scanning Books</div>
|
||||
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item error">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
|
||||
<div class="accent-text mb-1">Click for more information</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item info">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
|
||||
<div class="accent-text mb-1">Click for more information</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" aria-label="close" ></button>
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="d-inline-flex">
|
||||
<span class="download">
|
||||
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
|
||||
<span class="visually-hidden" role="status">
|
||||
10% downloaded
|
||||
</span>
|
||||
</span>
|
||||
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
|
||||
</div>
|
||||
<div class="accent-text">PDFs</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
@if(errors$ | async; as errors) {
|
||||
@if(infos$ | async; as infos) {
|
||||
@if (errors.length > 0 || infos.length > 0) {
|
||||
<li class="list-group-item dark-menu-item clickable" (click)="clearAllErrorOrInfos()">
|
||||
{{t('dismiss-all')}}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<!-- Progress Events-->
|
||||
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
|
||||
<ng-container *ngFor="let message of progressUpdates">
|
||||
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
|
||||
<div class="h6 mb-1">{{message.title}}</div>
|
||||
@if (message.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
}
|
||||
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
|
||||
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}</div>
|
||||
}
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
@if(message.progress === 'indeterminate') {
|
||||
<div class="progress" style="height: 5px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
<ng-template #progressEvent>
|
||||
@if (progressEvents$ | async; as progressUpdates) {
|
||||
@for (message of progressUpdates; track message) {
|
||||
@if (message.progress === 'indeterminate' || message.progress === 'none') {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">{{message.title}}</div>
|
||||
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
@if (message.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
}
|
||||
@if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) {
|
||||
<div class="accent-text mb-1" [title]="t('left-to-process', {leftToProcess: message.body.leftToProcess})">
|
||||
{{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}
|
||||
</div>
|
||||
}
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
@if(message.progress === 'indeterminate') {
|
||||
<div class="progress" style="height: 5px;">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
} @else {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">{{message.title}}</div>
|
||||
@if (message.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="message.subTitle">{{message.subTitle}}</div>
|
||||
}
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{prettyPrintProgress(message.body.progress) + '%'}}</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': message.body.progress * 100 + '%'}" [attr.aria-valuenow]="message.body.progress * 100" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
<div class="progress-bar" role="progressbar"
|
||||
[ngStyle]="{'width': message.body.progress * 100 + '%'}"
|
||||
[attr.aria-valuenow]="message.body.progress * 100"
|
||||
aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Single updates (Informational/Update available)-->
|
||||
<ng-container *ngIf="singleUpdates$ | async as singleUpdates">
|
||||
<ng-container *ngFor="let singleUpdate of singleUpdates">
|
||||
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name === EVENTS.UpdateAvailable" (click)="handleUpdateAvailableClick(singleUpdate)">
|
||||
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
|
||||
</li>
|
||||
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name !== EVENTS.UpdateAvailable">
|
||||
<div>{{singleUpdate.title}}</div>
|
||||
<div class="accent-text" *ngIf="singleUpdate.subTitle !== ''">{{singleUpdate.subTitle}}</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@if (singleUpdates$ | async; as singleUpdates) {
|
||||
@for(singleUpdate of singleUpdates; track singleUpdate) {
|
||||
@if (singleUpdate.name === EVENTS.UpdateAvailable) {
|
||||
<li class="list-group-item dark-menu-item update-available" (click)="handleUpdateAvailableClick(singleUpdate)">
|
||||
<i class="fa fa-chevron-circle-up me-1" aria-hidden="true"></i>{{t('update-available')}}
|
||||
</li>
|
||||
} @else {
|
||||
<li class="list-group-item dark-menu-item update-available">
|
||||
<div>{{singleUpdate.title}}</div>
|
||||
@if (singleUpdate.subTitle !== '') {
|
||||
<div class="accent-text">{{singleUpdate.subTitle}}</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Active Downloads by the user-->
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<ng-container *ngFor="let download of activeDownloads">
|
||||
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
|
||||
@for(download of activeDownloads; track download) {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div class="h6 mb-1">{{t('downloading-item', {item: download.entityType | sentenceCase})}}</div>
|
||||
<div class="accent-text mb-1" *ngIf="download.subTitle !== ''" [title]="download.subTitle">{{download.subTitle}}</div>
|
||||
|
||||
@if (download.subTitle !== '') {
|
||||
<div class="accent-text mb-1" [title]="download.subTitle">{{download.subTitle}}</div>
|
||||
}
|
||||
|
||||
<div class="progress-container row g-0 align-items-center">
|
||||
<div class="col-2">{{download.progress}}%</div>
|
||||
<div class="col-10 progress" style="height: 5px;">
|
||||
|
@ -141,57 +120,49 @@
|
|||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ng-container>
|
||||
@if(activeDownloads.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">{{activeDownloads.length}} downloads in Queue</li>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
@if(activeDownloads.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">{{t('download-in-queue', {num: activeDownloads.length})}}</li>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Errors -->
|
||||
<ng-container *ngIf="errors$ | async as errors">
|
||||
<ng-container *ngFor="let error of errors">
|
||||
@if (errors$ | async; as errors) {
|
||||
@for (error of errors; track error) {
|
||||
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMore(error)">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>{{error.title}}</div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2" aria-hidden="true"></i>{{error.title}}</div>
|
||||
<div class="accent-text mb-1">{{t('more-info')}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(error, $event)"></button>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Infos -->
|
||||
<ng-container *ngIf="infos$ | async as infos">
|
||||
<ng-container *ngFor="let info of infos">
|
||||
@if (infos$ | async; as infos) {
|
||||
@for (info of infos; track info) {
|
||||
<li class="list-group-item dark-menu-item info" role="alert" (click)="seeMore(info)">
|
||||
<div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>{{info.title}}</div>
|
||||
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2" aria-hidden="true"></i>{{info.title}}</div>
|
||||
<div class="accent-text mb-1">{{t('more-info')}}</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close float-end" [attr.aria-label]="t('close')" (click)="removeErrorOrInfo(info, $event)"></button>
|
||||
</li>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Online Users -->
|
||||
@if (messageHub.onlineUsers$ | async; as onlineUsers) {
|
||||
@if (onlineUsers.length > 1) {
|
||||
<li class="list-group-item dark-menu-item">
|
||||
<div>{{t('users-online-count', {num: onlineUsers.length})}}</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (debugMode) {
|
||||
<li class="list-group-item dark-menu-item">{{t('active-events-title')}} {{activeEvents}}</li>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
|
||||
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && activeDownloads.length === 0">{{t('no-data')}}</li>
|
||||
</ng-container>
|
||||
|
||||
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
|
||||
@if (errors$ | async; as errors) {
|
||||
@if (infos$ | async; as infos) {
|
||||
@if (infos.length === 0 && errors.length === 0 && activeDownloads.length === 0 && activeEvents === 0) {
|
||||
<li class="list-group-item dark-menu-item">{{t('no-data')}}</li>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -14,6 +14,26 @@
|
|||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.colored {
|
||||
color: var(--event-widget-activity-bg-color) !important;
|
||||
}
|
||||
|
||||
.widget-button--indicator {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
color: var(--event-widget-activity-bg-color);
|
||||
|
||||
&.error {
|
||||
color: var(--event-widget-error-bg-color) !important;
|
||||
}
|
||||
&.info {
|
||||
color: var(--event-widget-info-bg-color) !important;
|
||||
}
|
||||
&.update {
|
||||
color: var(--event-widget-update-bg-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .nav-events {
|
||||
|
||||
.popover-body {
|
||||
|
@ -56,67 +76,56 @@
|
|||
|
||||
|
||||
.btn-icon {
|
||||
color: white;
|
||||
color: var(--event-widget-text-color);
|
||||
}
|
||||
|
||||
.colored {
|
||||
background-color: var(--primary-color);
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.colored-error {
|
||||
background-color: var(--error-color) !important;
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.colored-info {
|
||||
background-color: var(--event-widget-info-bg-color) !important;
|
||||
border-radius: 60px;
|
||||
}
|
||||
|
||||
.update-available {
|
||||
.dark-menu-item {
|
||||
&.update-available {
|
||||
cursor: pointer;
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
&.error {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
.h6 {
|
||||
color: var(--error-color);
|
||||
color: var(--event-widget-error-bg-color);
|
||||
}
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
&.info {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
.h6 {
|
||||
color: var(--event-widget-info-bg-color);
|
||||
color: var(--event-widget-info-bg-color);
|
||||
}
|
||||
|
||||
i.fa {
|
||||
color: var(--primary-color) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hu
|
|||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe';
|
||||
import { CircularLoaderComponent } from '../../../shared/circular-loader/circular-loader.component';
|
||||
import { NgIf, NgClass, NgStyle, NgFor, AsyncPipe } from '@angular/common';
|
||||
import { NgClass, NgStyle, AsyncPipe } from '@angular/common';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
|
@ -34,12 +34,20 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||
styleUrls: ['./events-widget.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgClass, NgbPopover, NgStyle, CircularLoaderComponent, NgFor, AsyncPipe, SentenceCasePipe, TranslocoDirective]
|
||||
imports: [NgClass, NgbPopover, NgStyle, CircularLoaderComponent, AsyncPipe, SentenceCasePipe, TranslocoDirective]
|
||||
})
|
||||
export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
@Input({required: true}) user!: User;
|
||||
public readonly downloadService = inject(DownloadService);
|
||||
public readonly messageHub = inject(MessageHubService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@Input({required: true}) user!: User;
|
||||
|
||||
|
||||
isAdmin$: Observable<boolean> = of(false);
|
||||
|
||||
/**
|
||||
|
@ -60,17 +68,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||
|
||||
activeEvents: number = 0;
|
||||
/**
|
||||
* Intercepts from Single Updates to show an extra indicator to the user
|
||||
*/
|
||||
updateAvailable: boolean = false;
|
||||
|
||||
debugMode: boolean = false;
|
||||
|
||||
protected readonly EVENTS = EVENTS;
|
||||
|
||||
public readonly downloadService = inject(DownloadService);
|
||||
|
||||
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
|
||||
private accountService: AccountService, private confirmService: ConfirmService,
|
||||
private readonly cdRef: ChangeDetectorRef) {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.progressEventsSource.complete();
|
||||
|
@ -115,6 +121,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
|
|||
values.push(message);
|
||||
this.singleUpdateSource.next(values);
|
||||
this.activeEvents += 1;
|
||||
if (event.payload.name === EVENTS.UpdateAvailable) {
|
||||
this.updateAvailable = true;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
break;
|
||||
case 'started':
|
||||
|
|
|
@ -1,346 +1,393 @@
|
|||
<ng-container *transloco="let t; read: 'series-detail'">
|
||||
<div #companionBar>
|
||||
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
|
||||
<ng-container title>
|
||||
<h2 class="title text-break">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<span>{{series.name}}
|
||||
@if(isLoadingExtra || isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
@if (series) {
|
||||
<app-side-nav-companion-bar [hasExtras]="true" [extraDrawer]="extrasDrawer">
|
||||
<ng-container title>
|
||||
<h2 class="title text-break">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<span>{{series.name}}
|
||||
@if(isLoadingExtra || isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">loading...</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</h2>
|
||||
</ng-container>
|
||||
<ng-container subtitle *ngIf="series.localizedName !== series.name">
|
||||
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
|
||||
</ng-container>
|
||||
</h2>
|
||||
</ng-container>
|
||||
@if (series.localizedName !== series.name) {
|
||||
<ng-container subtitle>
|
||||
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px">
|
||||
<div class="offcanvas-header">
|
||||
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="offcanvas.dismiss()"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<form [formGroup]="pageExtrasGroup">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-3">
|
||||
<label id="list-layout-mode-label" class="form-label">{{t('layout-mode-label')}}</label>
|
||||
<br/>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('page-settings-title')">
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-card')}}</label>
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px">
|
||||
<div class="offcanvas-header">
|
||||
<h4 class="offcanvas-title" id="offcanvas-basic-title">{{t('page-settings-title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="offcanvas.dismiss()"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<form [formGroup]="pageExtrasGroup">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-12 col-sm-12 pe-2 mb-3">
|
||||
<label id="list-layout-mode-label" class="form-label">{{t('layout-mode-label')}}</label>
|
||||
<br/>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('page-settings-title')">
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-card')}}</label>
|
||||
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
|
||||
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-list')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
|
||||
</app-side-nav-companion-bar>
|
||||
</app-side-nav-companion-bar>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback" [topOffset]="56"></app-bulk-operations>
|
||||
|
||||
@if (series) {
|
||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" #scrollingBlock>
|
||||
<div class="row mb-0 mb-xl-3 info-container">
|
||||
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block mt-2">
|
||||
@if (unreadCount > 0 && unreadCount !== totalCount) {
|
||||
<div class="to-read-counter">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="series !== undefined" #scrollingBlock>
|
||||
<div class="row mb-0 mb-xl-3 info-container">
|
||||
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block mt-2">
|
||||
<div class="to-read-counter" *ngIf="unreadCount > 0 && unreadCount !== totalCount">
|
||||
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
|
||||
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
|
||||
@if (series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial) {
|
||||
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="under-image">
|
||||
{{t('continue-from', {title: ContinuePointTitle})}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<app-image [styles]="{'object-fit': 'contain', 'background': 'none', 'max-height': '400px', 'height': '100%'}" [imageUrl]="seriesImage"></app-image>
|
||||
<ng-container *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
|
||||
<div class="progress-banner" ngbTooltip="{{(series.pagesRead / series.pages) * 100 | number:'1.0-1'}}% Read">
|
||||
<ngb-progressbar type="primary" height="5px" [value]="series.pagesRead" [max]="series.pages"></ngb-progressbar>
|
||||
</div>
|
||||
<div class="under-image">
|
||||
{{t('continue-from', {title: ContinuePointTitle})}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" (click)="read()">
|
||||
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary" (click)="read()">
|
||||
<span>
|
||||
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue') : t('read')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
</button>
|
||||
<div class="btn-group" ngbDropdown role="group" display="dynamic" [attr.aria-label]="t('read-options-alt')">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle-split" ngbDropdownToggle></button>
|
||||
<div class="dropdown-menu" ngbDropdownMenu>
|
||||
<button ngbDropdownItem (click)="read(true)">
|
||||
<span>
|
||||
<i class="fa fa-glasses" aria-hidden="true"></i>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? t('continue-incognito') : t('read-incognito')}}</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" (click)="toggleWantToRead()" title="{{isWantToRead ? t('remove-from-want-to-read') : t('add-to-want-to-read')}}">
|
||||
<span>
|
||||
<i class="{{isWantToRead ? 'fa-solid' : 'fa-regular'}} fa-star" aria-hidden="true"></i>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2" *ngIf="isAdmin">
|
||||
<button class="btn btn-secondary" id="edit-btn--komf" (click)="openEditSeriesModal()" [title]="t('edit-series-alt')">
|
||||
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<div class="card-actions">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
||||
</button>
|
||||
</div>
|
||||
@if (isAdmin) {
|
||||
<div class="col-auto ms-2">
|
||||
<button class="btn btn-secondary" id="edit-btn--komf" (click)="openEditSeriesModal()" [title]="t('edit-series-alt')">
|
||||
<span><i class="fa fa-pen" aria-hidden="true"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<div class="card-actions">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isAdmin || hasDownloadingRole) {
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
@if (download$ | async; as download) {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
|
||||
@if (download !== null) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||
} @else {
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
|
||||
@if (download$ | async; as download) {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
|
||||
<ng-container *ngIf="download !== null; else notDownloading">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{{t('downloading-status')}}</span>
|
||||
</ng-container>
|
||||
<ng-template #notDownloading>
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (seriesMetadata) {
|
||||
<div class="mt-2">
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||
[libraryType]="libraryType" [ratings]="ratings"
|
||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@if (seriesMetadata) {
|
||||
<div class="mt-2">
|
||||
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
|
||||
[libraryType]="libraryType" [ratings]="ratings"
|
||||
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
|
||||
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
|
||||
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
|
||||
[clickableTitle]="true" (sectionClick)="openReviewModal()">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata.summary.length === 0}">
|
||||
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-alt')"
|
||||
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
|
||||
[clickableTitle]="true" (sectionClick)="openReviewModal()">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
</div>
|
||||
@if (series) {
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="true" (navChange)="onNavChange($event)">
|
||||
|
||||
<ng-container *ngIf="series">
|
||||
@if (showStorylineTab) {
|
||||
<li [ngbNavItem]="TabID.Storyline">
|
||||
<a ngbNavLink>{{t('storyline-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
|
||||
<li [ngbNavItem]="TabID.Storyline" *ngIf="ShowStorylineTab">
|
||||
<a ngbNavLink>{{t('storyline-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else storylineListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
|
||||
{{item.id}}
|
||||
<ng-container [ngSwitch]="item.isChapter">
|
||||
<ng-container *ngSwitchCase="false" [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
|
||||
<ng-container *ngSwitchCase="true" [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #storylineListLayout>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
|
||||
<ng-container [ngSwitch]="item.isChapter">
|
||||
<ng-container *ngSwitchCase="false" [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
|
||||
<ng-container *ngSwitchCase="true" [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
@if (item.isChapter) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item.chapter, scroll: scroll, idx: idx, chaptersLength: storyChapters.length}"></ng-container>
|
||||
} @else {
|
||||
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item.volume, scroll: scroll, idx: idx, volumesLength: volumes.length}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Storyline}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
@if (item.isChapter) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item.chapter}"></ng-container>
|
||||
} @else {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item.volume}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Volumes" *ngIf="ShowVolumeTab">
|
||||
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else volumeListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
|
||||
</ng-container>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #volumeListLayout>
|
||||
<ng-container *ngFor="let volume of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: volume}"></ng-container>
|
||||
</ng-container>
|
||||
|
||||
@if (showVolumeTab) {
|
||||
<li [ngbNavItem]="TabID.Volumes">
|
||||
<a ngbNavLink>{{UseBookLogic ? t('books-tab') : t('volumes-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonChapterVolumeCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: volumes.length}"></ng-container>
|
||||
}
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Volumes}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialVolumeListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Chapters" *ngIf="ShowChaptersTab">
|
||||
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else chapterListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<div *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container>
|
||||
@if (showChapterTab) {
|
||||
<li [ngbNavItem]="TabID.Chapters">
|
||||
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, totalLength: chapters.length}"></ng-container>
|
||||
}
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for (item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (hasSpecials) {
|
||||
<li [ngbNavItem]="TabID.Specials">
|
||||
<a ngbNavLink>{{t('specials-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id + '_' + item.pagesRead) {
|
||||
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: item}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (hasRelations) {
|
||||
<li [ngbNavItem]="TabID.Related">
|
||||
<a ngbNavLink>{{t('related-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track item.id) {
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||
}
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="estimatedNextCard" [ngTemplateOutletContext]="{tabId: TabID.Chapters}"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #chapterListLayout>
|
||||
<div *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="nonSpecialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
|
||||
<a ngbNavLink>{{t('specials-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else specialListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="specialChapterCard" [ngTemplateOutletContext]="{$implicit: item, scroll: scroll, idx: idx, chaptersLength: chapters.length}"></ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #specialListLayout>
|
||||
<ng-container *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<ng-container [ngTemplateOutlet]="specialChapterListItem" [ngTemplateOutletContext]="{$implicit: chapter}"></ng-container>
|
||||
</ng-container>
|
||||
@if (hasRecommendations) {
|
||||
<li [ngbNavItem]="TabID.Recommendations">
|
||||
<a ngbNavLink>{{t('recommendations-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
@switch (renderMode) {
|
||||
@case (PageLayoutMode.Cards) {
|
||||
<div class="card-container row g-0" #container>
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track idx) {
|
||||
@if (!item.hasOwnProperty('coverUrl')) {
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
|
||||
} @else {
|
||||
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case (PageLayoutMode.List) {
|
||||
@for(item of scroll.viewPortItems; let idx = $index; track idx) {
|
||||
@if (!item.hasOwnProperty('coverUrl')) {
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
} @else {
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Related" *ngIf="hasRelations">
|
||||
<a ngbNavLink>{{t('related-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems let idx = index; trackBy: trackByRelatedSeriesIdentify">
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
|
||||
</ng-container>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabID.Recommendations" *ngIf="hasRecommendations">
|
||||
<a ngbNavLink>{{t('recommendations-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<virtual-scroller #scroll [items]="combinedRecs" [parentScroll]="scrollingBlock" [childHeight]="1">
|
||||
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else recListLayout">
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
|
||||
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
|
||||
</ng-container>
|
||||
<ng-template #externalRec>
|
||||
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #recListLayout>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
|
||||
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
|
||||
<app-external-list-item [imageUrl]="imageService.getSeriesCoverImage(item.id)" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, false); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
</ng-container>
|
||||
<ng-template #externalRec>
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
}
|
||||
|
||||
</virtual-scroller>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
</ng-container>
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
</div>
|
||||
<ng-template #estimatedNextCard let-tabId="tabId">
|
||||
@if (nextExpectedChapter) {
|
||||
@switch (tabId) {
|
||||
@case (TabID.Volumes) {
|
||||
@if (nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber) {
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter"
|
||||
[imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
}
|
||||
}
|
||||
@case (TabID.Chapters) {
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
}
|
||||
@case (TabID.Storyline) {
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
</div>
|
||||
<ng-template #estimatedNextCard let-tabId="tabId">
|
||||
<ng-container *ngIf="nextExpectedChapter">
|
||||
<ng-container [ngSwitch]="tabId">
|
||||
<ng-container *ngSwitchCase="TabID.Volumes">
|
||||
<app-next-expected-card *ngIf="nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber"
|
||||
class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter"
|
||||
[imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="TabID.Chapters">
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="TabID.Storyline">
|
||||
<app-next-expected-card class="col-auto mt-2 mb-2" [entity]="nextExpectedChapter" [imageUrl]="imageService.getSeriesCoverImage(series.id)"></app-next-expected-card>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #nonSpecialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
|
||||
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||
[count]="item.files.length"
|
||||
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
@if (!item.isSpecial) {
|
||||
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.title" (click)="openChapter(item)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||
[count]="item.files.length"
|
||||
(selection)="bulkSelectionService.handleCardSelection('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('chapter', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #nonChapterVolumeCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
|
||||
<app-card-item *ngIf="item.number !== LooseLeafOrSpecialNumber" class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
@if (item.number !== LooseLeafOrSpecialNumber) {
|
||||
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('volume', scroll.viewPortInfo.startIndexWithBuffer + idx, totalLength, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('volume', scroll.viewPortInfo.startIndexWithBuffer + idx)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #specialChapterCard let-item let-scroll="scroll" let-idx="idx" let-totalLength="totalLength">
|
||||
|
@ -354,27 +401,32 @@
|
|||
</ng-template>
|
||||
|
||||
<ng-template #nonSpecialChapterListItem let-item>
|
||||
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item" *ngIf="!item.isSpecial"
|
||||
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
@if (!item.isSpecial) {
|
||||
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item"
|
||||
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openChapter(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #nonSpecialVolumeListItem let-item>
|
||||
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item" *ngIf="item.number !== LooseLeafOrSpecialNumber"
|
||||
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
@if (item.number !== LooseLeafOrSpecialNumber) {
|
||||
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.id)" [libraryId]="libraryId"
|
||||
[seriesName]="series.name" [entity]="item"
|
||||
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
|
||||
[pagesRead]="item.pagesRead" [totalPages]="item.pages" (read)="openVolume(item)"
|
||||
[blur]="user?.preferences?.blurUnreadSummaries || false">
|
||||
<ng-container title>
|
||||
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
|
||||
</ng-container>
|
||||
</app-list-item>
|
||||
}
|
||||
|
||||
|
||||
</ng-template><ng-template #specialChapterListItem let-item>
|
||||
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.id)" [libraryId]="libraryId"
|
||||
|
|
|
@ -104,7 +104,7 @@ import {TagBadgeComponent} from '../../../shared/tag-badge/tag-badge.component';
|
|||
import {
|
||||
SideNavCompanionBarComponent
|
||||
} from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {ExternalSeries} from "../../../_models/series-detail/external-series";
|
||||
import {
|
||||
|
@ -282,6 +282,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
});
|
||||
|
||||
user: User | undefined;
|
||||
showVolumeTab = true;
|
||||
showStorylineTab = true;
|
||||
showChapterTab = true;
|
||||
|
||||
/**
|
||||
* This is the download we get from download service.
|
||||
|
@ -337,28 +340,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
}
|
||||
}
|
||||
|
||||
get ShowStorylineTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) return false;
|
||||
// Edge case for bad pdf parse
|
||||
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
|
||||
|
||||
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
|
||||
&& (this.volumes.length > 0 || this.chapters.length > 0);
|
||||
}
|
||||
|
||||
get ShowVolumeTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) {
|
||||
if (this.volumes.length > 1) return true;
|
||||
if (this.specials.length === 0 && this.chapters.length === 0) return true;
|
||||
return false;
|
||||
}
|
||||
return this.volumes.length > 0;
|
||||
}
|
||||
|
||||
get ShowChaptersTab() {
|
||||
return this.chapters.length > 0;
|
||||
}
|
||||
|
||||
get UseBookLogic() {
|
||||
return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel;
|
||||
}
|
||||
|
@ -380,26 +361,43 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
if (!this.currentlyReadingChapter.isSpecial) {
|
||||
const vol = this.volumes.filter(v => v.id === this.currentlyReadingChapter?.volumeId);
|
||||
|
||||
let chapterLocaleKey = 'common.chapter-num-shorthand';
|
||||
let volumeLocaleKey = 'common.volume-num-shorthand';
|
||||
switch (this.libraryType) {
|
||||
case LibraryType.ComicVine:
|
||||
case LibraryType.Comic:
|
||||
chapterLocaleKey = 'common.issue-num-shorthand';
|
||||
break;
|
||||
case LibraryType.Book:
|
||||
case LibraryType.Manga:
|
||||
case LibraryType.LightNovel:
|
||||
case LibraryType.Images:
|
||||
chapterLocaleKey = 'common.chapter-num-shorthand';
|
||||
break;
|
||||
}
|
||||
|
||||
// This is a lone chapter
|
||||
if (vol.length === 0) {
|
||||
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
|
||||
return this.currentlyReadingChapter.titleName;
|
||||
}
|
||||
return 'Ch ' + this.currentlyReadingChapter.minNumber; // TODO: Refactor this to use DisplayTitle (or Range) and Localize it
|
||||
return translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
|
||||
}
|
||||
|
||||
if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) {
|
||||
return 'Vol ' + vol[0].minNumber;
|
||||
return translate(chapterLocaleKey, {num: vol[0].minNumber});
|
||||
}
|
||||
return 'Vol ' + vol[0].minNumber + ' Ch ' + this.currentlyReadingChapter.minNumber;
|
||||
return translate(volumeLocaleKey, {num: vol[0].minNumber})
|
||||
+ ' ' + translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber});
|
||||
}
|
||||
|
||||
return this.currentlyReadingChapter.title;
|
||||
}
|
||||
|
||||
|
||||
constructor(@Inject(DOCUMENT) private document: Document) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
this.accountService.currentUser$.subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
|
@ -415,6 +413,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
this.scrollService.setScrollContainer(this.scrollingBlock);
|
||||
}
|
||||
|
||||
debugLog(message: string) {
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
const routeId = this.route.snapshot.paramMap.get('seriesId');
|
||||
|
@ -424,7 +426,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
return;
|
||||
}
|
||||
|
||||
// Setup the download in progress
|
||||
// Set up the download in progress
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
return this.downloadService.mapToEntityType(events, this.series);
|
||||
}));
|
||||
|
@ -652,12 +654,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
|
||||
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
|
||||
|
||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||
.filter(action => action.action !== Action.Edit);
|
||||
|
||||
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
||||
|
||||
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
|
||||
.filter(action => action.action !== Action.Edit);
|
||||
|
||||
|
||||
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
|
||||
|
@ -677,6 +677,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
...relations.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)),
|
||||
...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)),
|
||||
];
|
||||
|
||||
if (this.relations.length > 0) {
|
||||
this.hasRelations = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -690,7 +691,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
this.router.navigateByUrl('/home');
|
||||
return of(null);
|
||||
})).subscribe(detail => {
|
||||
if (detail == null) return;
|
||||
if (detail == null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
return;
|
||||
}
|
||||
|
||||
this.unreadCount = detail.unreadCount;
|
||||
this.totalCount = detail.totalCount;
|
||||
|
||||
|
@ -700,6 +705,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
this.chapters = detail.chapters;
|
||||
this.volumes = detail.volumes;
|
||||
this.storyChapters = detail.storylineChapters;
|
||||
|
||||
this.storylineItems = [];
|
||||
const v = this.volumes.map(v => {
|
||||
return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem;
|
||||
|
@ -710,10 +716,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
});
|
||||
this.storylineItems.push(...c);
|
||||
|
||||
|
||||
this.updateWhichTabsToShow();
|
||||
this.updateSelectedTab();
|
||||
|
||||
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('isLoading is now false')
|
||||
});
|
||||
}, err => {
|
||||
this.router.navigateByUrl('/home');
|
||||
|
@ -724,6 +733,35 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
return {series, relation} as RelatedSeriesPair;
|
||||
}
|
||||
|
||||
shouldShowStorylineTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) return false;
|
||||
// Edge case for bad pdf parse
|
||||
if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true;
|
||||
|
||||
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
|
||||
&& (this.volumes.length > 0 || this.chapters.length > 0);
|
||||
}
|
||||
|
||||
shouldShowVolumeTab() {
|
||||
if (this.libraryType === LibraryType.ComicVine) {
|
||||
if (this.volumes.length > 1) return true;
|
||||
if (this.specials.length === 0 && this.chapters.length === 0) return true;
|
||||
return false;
|
||||
}
|
||||
return this.volumes.length > 0;
|
||||
}
|
||||
|
||||
shouldShowChaptersTab() {
|
||||
return this.chapters.length > 0;
|
||||
}
|
||||
|
||||
updateWhichTabsToShow() {
|
||||
this.showVolumeTab = this.shouldShowVolumeTab();
|
||||
this.showStorylineTab = this.shouldShowStorylineTab();
|
||||
this.showChapterTab = this.shouldShowChaptersTab();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* This will update the selected tab
|
||||
*
|
||||
|
@ -771,10 +809,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
loadPlusMetadata(seriesId: number, libraryType: LibraryType) {
|
||||
this.isLoadingExtra = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => {
|
||||
this.isLoadingExtra = false;
|
||||
this.cdRef.markForCheck();
|
||||
if (data === null) return;
|
||||
if (data === null) {
|
||||
this.isLoadingExtra = false;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('isLoadingExtra is false')
|
||||
return;
|
||||
}
|
||||
|
||||
// Reviews
|
||||
this.reviews = [...data.reviews];
|
||||
|
@ -790,7 +832,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
|
||||
this.hasRecommendations = this.combinedRecs.length > 0;
|
||||
|
||||
this.isLoadingExtra = false;
|
||||
this.cdRef.markForCheck();
|
||||
console.log('isLoadingExtra is false')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -970,11 +1014,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
|||
|
||||
downloadSeries() {
|
||||
this.downloadService.download('series', this.series, (d) => {
|
||||
if (d) {
|
||||
this.downloadInProgress = true;
|
||||
} else {
|
||||
this.downloadInProgress = false;
|
||||
}
|
||||
this.downloadInProgress = !!d;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -220,6 +220,7 @@ export class DownloadService {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
private getIdKey(entity: Chapter | Volume) {
|
||||
if (this.utilityService.isVolume(entity)) return 'volumeId';
|
||||
if (this.utilityService.isChapter(entity)) return 'chapterId';
|
||||
|
|
|
@ -1,27 +1,30 @@
|
|||
<ng-container *ngIf="currentValue > 0">
|
||||
<div [ngClass]="{'number': center}" class="indicator" *ngIf="showIcon">
|
||||
@if (currentValue > 0) {
|
||||
@if (showIcon) {
|
||||
<div [ngClass]="{'number': center}" class="indicator">
|
||||
<i class="fa fa-angle-double-down" [ngStyle]="{'font-size': fontSize}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div [ngStyle]="{'width': width, 'height': height}">
|
||||
<circle-progress
|
||||
[percent]="currentValue"
|
||||
[radius]="100"
|
||||
[outerStrokeWidth]="15"
|
||||
[innerStrokeWidth]="0"
|
||||
[space] = "0"
|
||||
[backgroundPadding]="0"
|
||||
outerStrokeLinecap="butt"
|
||||
[outerStrokeColor]="outerStrokeColor"
|
||||
[innerStrokeColor]="innerStrokeColor"
|
||||
titleFontSize= "24"
|
||||
unitsFontSize= "24"
|
||||
[showSubtitle] = "false"
|
||||
[animation]="animation"
|
||||
[animationDuration]="300"
|
||||
[startFromZero]="false"
|
||||
[responsive]="true"
|
||||
[backgroundOpacity]="0.5"
|
||||
[backgroundColor]="backgroundColor"
|
||||
></circle-progress>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div [ngStyle]="{'width': width, 'height': height}">
|
||||
<circle-progress
|
||||
[percent]="currentValue"
|
||||
[radius]="100"
|
||||
[outerStrokeWidth]="15"
|
||||
[innerStrokeWidth]="0"
|
||||
[space] = "0"
|
||||
[backgroundPadding]="0"
|
||||
outerStrokeLinecap="butt"
|
||||
[outerStrokeColor]="outerStrokeColor"
|
||||
[innerStrokeColor]="innerStrokeColor"
|
||||
titleFontSize= "24"
|
||||
unitsFontSize= "24"
|
||||
[showSubtitle] = "false"
|
||||
[animation]="animation"
|
||||
[animationDuration]="300"
|
||||
[startFromZero]="false"
|
||||
[responsive]="true"
|
||||
[backgroundOpacity]="0.5"
|
||||
[backgroundColor]="backgroundColor"
|
||||
></circle-progress>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {CommonModule, NgClass, NgStyle} from "@angular/common";
|
||||
import {NgCircleProgressModule } from "ng-circle-progress";
|
||||
|
||||
@Component({
|
||||
selector: 'app-circular-loader',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgCircleProgressModule],
|
||||
// providers: [
|
||||
// importProvidersFrom(NgCircleProgressModule),
|
||||
// ],
|
||||
imports: [NgCircleProgressModule, NgStyle, NgClass],
|
||||
templateUrl: './circular-loader.component.html',
|
||||
styleUrls: ['./circular-loader.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
|
@ -5,21 +5,28 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<form style="width: 100%" [formGroup]="listForm">
|
||||
<div class="mb-3" *ngIf="items.length >= 5">
|
||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
|
||||
@if (items.length >= 5) {
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">{{t('filter')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="listForm.get('filterQuery')?.setValue('');">{{t('clear')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center clickable" *ngFor="let item of items | filter: filterList; let i = index">
|
||||
{{item}}
|
||||
<button class="btn btn-primary" *ngIf="clicked !== undefined" (click)="handleClick(item)">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
|
||||
</button>
|
||||
</li>
|
||||
@for(item of items | filter: filterList; track item; let i = $index) {
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center clickable">
|
||||
{{item}}
|
||||
@if (clicked !== undefined) {
|
||||
<button class="btn btn-primary" (click)="handleClick(item)">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('open-filtered-search',{item: item})}}</span>
|
||||
</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Component, Input } from '@angular/core';
|
||||
import {Component, inject, Input} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { FilterPipe } from '../../../../_pipes/filter.pipe';
|
||||
import { NgIf, NgFor } from '@angular/common';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
|
||||
@Component({
|
||||
|
@ -10,9 +9,11 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
|||
templateUrl: './generic-list-modal.component.html',
|
||||
styleUrls: ['./generic-list-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, NgIf, NgFor, FilterPipe, TranslocoDirective]
|
||||
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective]
|
||||
})
|
||||
export class GenericListModalComponent {
|
||||
private readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input() items: Array<string> = [];
|
||||
@Input() title: string = '';
|
||||
@Input() clicked: ((item: string) => void) | undefined = undefined;
|
||||
|
@ -25,8 +26,6 @@ export class GenericListModalComponent {
|
|||
return listItem.toLowerCase().indexOf((this.listForm.value.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
constructor(private modal: NgbActiveModal) {}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
<table class="table table-striped table-striped table-hover table-sm scrollable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" sortable="extension" (sort)="onSort($event)">
|
||||
<th scope="col" sortable="extension" direction="asc" (sort)="onSort($event)">
|
||||
{{t('extension-header')}}
|
||||
</th>
|
||||
<th scope="col" sortable="format" (sort)="onSort($event)">
|
||||
|
@ -40,6 +40,7 @@
|
|||
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
|
||||
{{t('total-files-header')}}
|
||||
</th>
|
||||
<th scope="col">{{t('download-file-for-extension-header')}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
@ -56,6 +57,16 @@
|
|||
<td>
|
||||
{{item.totalFiles | number:'1.0-0'}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-icon" style="color: var(--primary-color)" (click)="export(item.extension)" [disabled]="downloadInProgress[item.extension]">
|
||||
@if (downloadInProgress[item.extension]) {
|
||||
<div class="spinner-border spinner-border-sm" aria-hidden="true"></div>
|
||||
} @else {
|
||||
<i class="fa-solid fa-file-arrow-down" aria-hidden="true"></i>
|
||||
}
|
||||
<span class="visually-hidden">{{t('download-file-for-extension-alt"', {extension: item.extension})}}</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
|
@ -73,6 +84,8 @@
|
|||
|
||||
</div>
|
||||
|
||||
<ng-template #modalTable>
|
||||
|
||||
</ng-template>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -16,12 +16,11 @@ import { PieDataItem } from '../../_models/pie-data-item';
|
|||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { MangaFormatPipe } from '../../../_pipes/manga-format.pipe';
|
||||
import { BytesPipe } from '../../../_pipes/bytes.pipe';
|
||||
import { SortableHeader as SortableHeader_1 } from '../../../_single-module/table/_directives/sortable-header.directive';
|
||||
import { NgIf, NgFor, AsyncPipe, DecimalPipe } from '@angular/common';
|
||||
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {filter, tap} from "rxjs/operators";
|
||||
import {GenericTableModalComponent} from "../_modals/generic-table-modal/generic-table-modal.component";
|
||||
import {Pagination} from "../../../_models/pagination";
|
||||
import {DownloadService} from "../../../shared/_services/download.service";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
export interface StackedBarChartDataItem {
|
||||
name: string,
|
||||
|
@ -34,7 +33,7 @@ export interface StackedBarChartDataItem {
|
|||
styleUrls: ['./file-breakdown-stats.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, SortableHeader_1, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoDirective]
|
||||
imports: [NgbTooltip, ReactiveFormsModule, NgIf, PieChartModule, NgFor, AsyncPipe, DecimalPipe, BytesPipe, MangaFormatPipe, TranslocoDirective, SortableHeader]
|
||||
})
|
||||
export class FileBreakdownStatsComponent {
|
||||
|
||||
|
@ -42,7 +41,7 @@ export class FileBreakdownStatsComponent {
|
|||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
@ViewChildren(SortableHeader<PieDataItem>) headers!: QueryList<SortableHeader<PieDataItem>>;
|
||||
@ViewChild('tablelayout') tableTemplate!: TemplateRef<any>;
|
||||
@ViewChild('modalTable') modalTable!: TemplateRef<any>;
|
||||
|
||||
rawData$!: Observable<FileExtensionBreakdown>;
|
||||
files$!: Observable<Array<FileExtension>>;
|
||||
|
@ -55,9 +54,10 @@ export class FileBreakdownStatsComponent {
|
|||
|
||||
formControl: FormControl = new FormControl(true, []);
|
||||
|
||||
downloadInProgress: {[key: string]: boolean} = {};
|
||||
|
||||
private readonly statService = inject(StatisticsService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly ngbModal = inject(NgbModal);
|
||||
|
||||
constructor() {
|
||||
this.rawData$ = this.statService.getFileBreakdown().pipe(takeUntilDestroyed(this.destroyRef), shareReplay());
|
||||
|
@ -80,17 +80,6 @@ export class FileBreakdownStatsComponent {
|
|||
this.vizData2$ = this.files$.pipe(takeUntilDestroyed(this.destroyRef), map(data => data.map(d => {
|
||||
return {name: d.extension || this.translocoService.translate('file-breakdown-stats.not-classified'), value: d.totalFiles, extra: d.totalSize};
|
||||
})));
|
||||
|
||||
// TODO: See if you can figure this out
|
||||
// this.formControl.valueChanges.pipe(filter(v => !v), takeUntilDestroyed(this.destroyRef), switchMap(_ => {
|
||||
// const ref = this.ngbModal.open(GenericTableModalComponent);
|
||||
// ref.componentInstance.title = translate('file-breakdown-stats.format-title');
|
||||
// ref.componentInstance.bodyTemplate = this.tableTemplate;
|
||||
// return ref.dismissed;
|
||||
// }, tap(_ => {
|
||||
// this.formControl.setValue(true);
|
||||
// this.cdRef.markForCheck();
|
||||
// }))).subscribe();
|
||||
}
|
||||
|
||||
onSort(evt: SortEvent<FileExtension>) {
|
||||
|
@ -104,4 +93,15 @@ export class FileBreakdownStatsComponent {
|
|||
});
|
||||
}
|
||||
|
||||
export(format: string) {
|
||||
this.downloadInProgress[format] = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.statService.downloadFileBreakdown(format)
|
||||
.subscribe(() => {
|
||||
this.downloadInProgress[format] = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1513,7 +1513,8 @@
|
|||
"users-online-count": "{{num}} Users online",
|
||||
"active-events-title": "Active Events:",
|
||||
"no-data": "Not much going on here",
|
||||
"left-to-process": "Left to Process: {{leftToProcess}}"
|
||||
"left-to-process": "Left to Process: {{leftToProcess}}",
|
||||
"download-in-queue": "{{num}} downloads in Queue"
|
||||
},
|
||||
|
||||
"shortcuts-modal": {
|
||||
|
@ -1868,7 +1869,9 @@
|
|||
"total-size-header": "Total Size",
|
||||
"total-files-header": "Total Files",
|
||||
"not-classified": "Not Classified",
|
||||
"total-file-size-title": "Total File Size:"
|
||||
"total-file-size-title": "Total File Size:",
|
||||
"download-file-for-extension-header": "Download Report",
|
||||
"download-file-for-extension-alt": "Download files Report for {{extension}}"
|
||||
},
|
||||
|
||||
"reading-activity": {
|
||||
|
@ -2326,7 +2329,10 @@
|
|||
"issue-hash-num": "Issue #",
|
||||
"issue-num": "Issue",
|
||||
"chapter-num": "Chapter",
|
||||
"volume-num": "Volume"
|
||||
"volume-num": "Volume",
|
||||
"chapter-num-shorthand": "Ch {{num}}",
|
||||
"issue-num-shorthand": "#{{num}}",
|
||||
"volume-num-shorthand": "Vol {{num}}"
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -242,6 +242,10 @@
|
|||
--event-widget-item-border-color: rgba(53, 53, 53, 0.5);
|
||||
--event-widget-border-color: rgba(1, 4, 9, 0.5);
|
||||
--event-widget-info-bg-color: #b6d4fe;
|
||||
--event-widget-error-bg-color: var(--error-color);
|
||||
--event-widget-update-bg-color: var(--primary-color);
|
||||
--event-widget-activity-bg-color: var(--primary-color);
|
||||
|
||||
|
||||
/* Search */
|
||||
--search-result-text-lite-color: initial;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue