Change Detection: On Push aka UI Smoothness (#1369)

* Updated Series Info Cards to use OnPush and hooked in progress events when we do a mark as read/unread on entities. These events update progress bars but also will now trigger a re-calculation on Read Time Left.

* Removed Library Card Component

* Refactored manga reader title and subtitle calculation to the backend.

* Coverted card actionables to onPush

* Series Card on push cleanup

* Updated edit collection tags for on push

* Update cover image chooser for on push

* Cleaned up carsouel reel

* Updated cover image to allow for uploading gif and webp files

* Bulk add to collection on push

* Updated bulk operation to use on push. Updated bulk operation to have mark as unread and read buttons explicitly. Updated so add to collection is visible and delete.

Fixed a bug where manage library component wasn't invoking the trackBy function

* Updating entity title for on push

* Removed file info component

* Updated Mange Library for on push

* Entity info cards on push

* List item on push

* Updated icon and title for on push and fixed some missing change detection on series detail

* Restricted the typeahead interface to simplify the design

* Edit Series Relation now shows a value in the dropdown for Parent relationships and disables the field.

* Updated edit series relation to focus on new typeahead when adding a new relationship

* Added some documentation and when Scanning a library, don't allow the user to enqueue the same job multiple times.

* Applied the No-enqueue if already enqueued logic to other tasks

* Library detail on push

* Updated events widget to onpush

* Card detail drawer on push. Card detail cover chooser now will show all chapter's covers for selection in cover chooser.

* Chapter metadata detail on push

* Removed Card Detail modal

* All collections on push

* Removed some comments

* Updated bulk selection to use an observable rather than function calls so new on push strategy works

* collection detail now uses on push and scroller is placed on correct element

* Updated library recommended to on push. Ensure that when mark as read occurs, the appropriate streams are refreshed.

* Updated library detail to on push

* Update metadata fiter to onpush. Bugs found and reported to Project

* person badge on push

* Read more on push

* Updated tag badge to on push

* User login on push

* When initing side nav, don't call an authenticated api until we are sure a user is logged in

* Updated splash container to on push

* Dashboard on push

* Side nav slight refactor around some api calls

* Cleaned up series card on push to use same cdRef naming convention

* Updated Static Files to use caching

* Added width and height to logo image

* shortcuts modal on push

* reading lists on push

* Reading list detail on push

* draggable ordered list on push

* Refactored reading-list-detail to use a new item which drastically reduces renders on operations

* series format on push

* circular loader on push

* Badge Expander on push

* update notification modal on push

* drawer on push

* Edit Series Modal on push

* reset password on push

* review series modal on push

* series metadata detail on push

* theme manager on push

* confirm reset password on push

* register on push

* confirm migration email on push

* confirm email on push

* add email to account migration on push

* user preferences on push. Made global settings default open

* edit series relation on push

* Fixed an edge case bug for next chapter where if the current volume had a single chapter of 1 and the next volume had a chapter number of 0, it would say there are no more chapters.

* Updated infinite scroller with on push support

* Moved some animations over to typeahead, not integrated yet.

* Manga reader is now on push

* Reader settings on push

* refactored how we close the book

* Updated table of contents for on push

* Updated book reader for on push. Fixed a bug where table of contents wasn't showing current page anchor due to a scroll calulation bug

* Small code tweak

* Icon and title on push

* nav header on push

* grouped typeahead on push

* typeahead on push and added a new trackby identity function to allow even faster rendering of big lists

* pdf reader on push

* code cleanup
This commit is contained in:
Joseph Milazzo 2022-07-11 11:57:07 -04:00 committed by GitHub
parent f5be0fac58
commit 4e49aa47ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 1658 additions and 1674 deletions

View file

@ -15,7 +15,7 @@
</div>
</div>
<ul class="list-group">
<li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let collectionTag of lists | filter: filterList; let i = index" (click)="addToCollection(collectionTag)">
<li class="list-group-item clickable" tabindex="0" role="button" *ngFor="let collectionTag of lists | filter: filterList; let i = index; trackBy: collectionTitleTrackby" (click)="addToCollection(collectionTag)">
{{collectionTag.title}} <i class="fa fa-angle-double-up" *ngIf="collectionTag.promoted" title="Promoted"></i>
</li>
<li class="list-group-item" *ngIf="lists.length === 0 && !loading">No collections created yet</li>

View file

@ -1,4 +1,4 @@
import { Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
import { FormGroup, FormControl } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
@ -10,7 +10,8 @@ import { CollectionTagService } from 'src/app/_services/collection-tag.service';
selector: 'app-bulk-add-to-collection',
templateUrl: './bulk-add-to-collection.component.html',
encapsulation: ViewEncapsulation.None, // This is needed as per the bootstrap modal documentation to get styles to work.
styleUrls: ['./bulk-add-to-collection.component.scss']
styleUrls: ['./bulk-add-to-collection.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BulkAddToCollectionComponent implements OnInit {
@ -27,10 +28,13 @@ export class BulkAddToCollectionComponent implements OnInit {
loading: boolean = false;
listForm: FormGroup = new FormGroup({});
collectionTitleTrackby = (index: number, item: CollectionTag) => `${item.title}`;
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService, private toastr: ToastrService) { }
constructor(private modal: NgbActiveModal, private collectionService: CollectionTagService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
@ -38,9 +42,11 @@ export class BulkAddToCollectionComponent implements OnInit {
this.listForm.addControl('filterQuery', new FormControl('', []));
this.loading = true;
this.cdRef.markForCheck();
this.collectionService.allTags().subscribe(tags => {
this.lists = tags;
this.loading = false;
this.cdRef.markForCheck();
});
}
@ -48,6 +54,7 @@ export class BulkAddToCollectionComponent implements OnInit {
// Shift focus to input
if (this.inputElem) {
this.inputElem.nativeElement.select();
this.cdRef.markForCheck();
}
}

View file

@ -1,144 +0,0 @@
<div *ngIf="data !== undefined">
<div class="modal-header">
<h4 *ngIf="libraryType !== LibraryType.Comic else comicHeader" class="modal-title" id="modal-basic-title">
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}} Details</h4>
<ng-template #comicHeader><h4 class="modal-title" id="modal-basic-title">
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}} Details</h4>
</ng-template>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]" *ngIf="!tabs[0].disabled">
<a ngbNavLink>{{tabs[0].title}}</a>
<ng-template ngbNavContent>
<div class="container-fluid row g-0">
<div class="col-md-2 col-xs-4 col-sm-6">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
ID: {{data.id}}
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row g-0">
<h4>
{{chapter?.titleName}}
</h4>
<span>
<span *ngIf="chapterMetadata && chapterMetadata.releaseDate !== null">Release Date: {{chapterMetadata.releaseDate | date: 'shortDate' || '-'}}</span>
</span>
<span class="text-accent">{{data.pages}} pages</span>
</div>
<div class="row g-0">
<div class="col-auto">
Added: {{(chapter.created | date: 'short') || '-'}}
</div>
</div>
<div class="row g-0">
<div class="col-auto">
Age Rating: {{ageRating}}
</div>
</div>
</div>
</div>
<div class="row g-0">
<ng-container *ngIf="chapterMetadata !== undefined">
<div class="row g-0" *ngIf="chapterMetadata.tags && chapterMetadata.tags.length > 0">
<h6>Tags</h6>
<app-badge-expander [items]="chapterMetadata.tags">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-tag-badge>{{item.title}}</app-tag-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="row g-0" *ngIf="chapterMetadata.genres && chapterMetadata.genres.length > 0">
<h6>Genres</h6>
<app-badge-expander [items]="chapterMetadata.genres">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-tag-badge>{{item.title}}</app-tag-badge>
</ng-template>
</app-badge-expander>
</div>
</ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]" *ngIf="!tabs[1].disabled">
<a ngbNavLink>{{tabs[1].title}}</a>
<ng-template ngbNavContent>
<app-chapter-metadata-detail [chapter]="chapterMetadata"></app-chapter-metadata-detail>
</ng-template>
</li>
<li [ngbNavItem]="tabs[2]" *ngIf="!tabs[2].disabled">
<a ngbNavLink>{{tabs[2].title}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
<div class="row g-0">
<button class="btn btn-primary flex-end mb-2" [disabled]="coverImageSaveLoading" (click)="saveCoverImage()">
<ng-container *ngIf="coverImageSaveLoading; else notSaving">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notSaving>
Save
</ng-template>
</button>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[3]" *ngIf="!tabs[3].disabled">
<a ngbNavLink>{{tabs[3].title}}</a>
<ng-template ngbNavContent>
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span >
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">UNREAD</span>
<span *ngIf="chapter.pagesRead === chapter.pages">READ</span>
</span>
</span>
<ng-template #specialHeader>Files</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
Pages: {{file.pages}}
</div>
<div class="col" *ngIf="data.hasOwnProperty('created')">
Added: {{(data.created | date: 'short') || '-'}}
</div>
</div>
</li>
</ul>
</div>
</li>
</ul>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
</div>
</div>

View file

@ -1,4 +0,0 @@
.scrollable-modal {
max-height: 90vh; // 600px
overflow: auto;
}

View file

@ -1,227 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { MangaFormat } from 'src/app/_models/manga-format';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { UploadService } from 'src/app/_services/upload.service';
import { LibraryType } from '../../../_models/library';
import { LibraryService } from '../../../_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
import { Series } from 'src/app/_models/series';
import { PersonRole } from 'src/app/_models/person';
import { Volume } from 'src/app/_models/volume';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { ReaderService } from 'src/app/_services/reader.service';
import { MetadataService } from 'src/app/_services/metadata.service';
@Component({
selector: 'app-card-details-modal',
templateUrl: './card-details-modal.component.html',
styleUrls: ['./card-details-modal.component.scss']
})
export class CardDetailsModalComponent implements OnInit {
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() libraryId: number = 0;
@Input() data!: Volume | Chapter; // Volume | Chapter
/**
* If this is a volume, this will be first chapter for said volume.
*/
chapter!: Chapter;
isChapter = false;
chapters: Chapter[] = [];
/**
* If a cover image update occured.
*/
coverImageUpdate: boolean = false;
coverImageIndex: number = 0;
/**
* Url of the selected cover
*/
selectedCover: string = '';
coverImageLocked: boolean = false;
/**
* When the API is doing work
*/
coverImageSaveLoading: boolean = false;
imageUrls: Array<string> = [];
actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
libraryType: LibraryType = LibraryType.Manga;
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}];
active = this.tabs[0];
chapterMetadata!: ChapterMetadata;
ageRating!: string;
get Breakpoint(): typeof Breakpoint {
return Breakpoint;
}
get PersonRole() {
return PersonRole;
}
get LibraryType(): typeof LibraryType {
return LibraryType;
}
constructor(public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.data);
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
});
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
if (!this.accountService.hasAdminRole(user)) {
this.tabs.find(s => s.title === 'Cover')!.disabled = true;
}
}
});
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
});
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit);
if (this.isChapter) {
this.chapters.push(this.data as Chapter);
} else if (!this.isChapter) {
this.chapters.push(...(this.data as Volume).chapters);
}
// TODO: Move this into the backend
this.chapters.sort(this.utilityService.sortChapters);
this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id));
// Try to show an approximation of the reading order for files
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
}
close() {
this.modal.close({coverImageUpdate: this.coverImageUpdate});
}
formatChapterNumber(chapter: Chapter) {
if (chapter.number === '0') {
return '1';
}
return chapter.number;
}
performAction(action: ActionItem<any>, chapter: Chapter) {
if (typeof action.callback === 'function') {
action.callback(action.action, chapter);
}
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
}
saveCoverImage() {
this.coverImageSaveLoading = true;
const selectedIndex = this.coverImageIndex || 0;
if (selectedIndex > 0) {
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
this.coverImageUpdate = true;
}
this.coverImageSaveLoading = false;
}, err => this.coverImageSaveLoading = false);
} else if (this.coverImageLocked === false) {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info('Cover image reset');
this.coverImageSaveLoading = false;
this.coverImageUpdate = true;
});
}
}
markChapterAsRead(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => { /* No Action */ });
}
markChapterAsUnread(chapter: Chapter) {
if (this.seriesId === 0) {
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { /* No Action */ });
}
handleChapterActionCallback(action: Action, chapter: Chapter) {
switch (action) {
case(Action.MarkAsRead):
this.markChapterAsRead(chapter);
break;
case(Action.MarkAsUnread):
this.markChapterAsUnread(chapter);
break;
case(Action.AddToReadingList):
this.actionService.addChapterToReadingList(chapter, this.seriesId);
break;
default:
break;
}
}
readChapter(chapter: Chapter) {
if (chapter.pages === 0) {
this.toastr.error('There are no pages. Kavita was not able to read this archive.');
return;
}
this.router.navigate(this.readerService.getNavigationArray(this.libraryId, this.seriesId, this.chapter.id, chapter.files[0].format));
}
}

View file

@ -1,16 +1,14 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General].id">
<a ngbNavLink>{{tabs[TabID.General].title}}</a>
<ng-template ngbNavContent>
<p>
<p class="alert alert-secondary" role="alert">
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
@ -52,7 +50,8 @@
<li [ngbNavItem]="tabs[TabID.CoverImage].id">
<a ngbNavLink>{{tabs[TabID.CoverImage].title}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
<p class="alert alert-secondary" role="alert">
<!-- TODO: I don't think we need this anymore, it's a bit intuitive -->
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>

View file

@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
@ -24,7 +24,8 @@ enum TabID {
@Component({
selector: 'app-edit-collection-tags',
templateUrl: './edit-collection-tags.component.html',
styleUrls: ['./edit-collection-tags.component.scss']
styleUrls: ['./edit-collection-tags.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditCollectionTagsComponent implements OnInit {
@ -58,7 +59,7 @@ export class EditCollectionTagsComponent implements OnInit {
private collectionService: CollectionTagService, private toastr: ToastrService,
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService,
public utilityService: UtilityService) { }
public utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
if (this.pagination == undefined) {
@ -97,6 +98,7 @@ export class EditCollectionTagsComponent implements OnInit {
this.isLoading = false;
this.libraryNames = results[1];
this.cdRef.markForCheck();
});
}
@ -108,15 +110,18 @@ export class EditCollectionTagsComponent implements OnInit {
} else if (numberOfSelected == this.series.length) {
this.selectAll = true;
}
this.cdRef.markForCheck();
}
togglePromotion() {
const originalPromotion = this.tag.promoted;
this.tag.promoted = !this.tag.promoted;
this.cdRef.markForCheck();
this.collectionService.updateTag(this.tag).subscribe(res => {
this.toastr.success('Tag updated successfully');
}, err => {
this.tag.promoted = originalPromotion;
this.cdRef.markForCheck();
});
}
@ -144,7 +149,7 @@ export class EditCollectionTagsComponent implements OnInit {
];
if (selectedIndex > 0) {
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover))
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover));
}
forkJoin(apis).subscribe(results => {
@ -161,12 +166,14 @@ export class EditCollectionTagsComponent implements OnInit {
updateSelectedImage(url: string) {
this.selectedCover = url;
this.cdRef.markForCheck();
}
handleReset() {
this.collectionTagForm.patchValue({
coverImageLocked: false
});
this.cdRef.markForCheck();
}
}

View file

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Observable, of, Subject } from 'rxjs';
@ -34,7 +34,8 @@ enum TabID {
@Component({
selector: 'app-edit-series-modal',
templateUrl: './edit-series-modal.component.html',
styleUrls: ['./edit-series-modal.component.scss']
styleUrls: ['./edit-series-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditSeriesModalComponent implements OnInit, OnDestroy {
@ -109,7 +110,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
private libraryService: LibraryService,
private collectionService: CollectionTagService,
private uploadService: UploadService,
private metadataService: MetadataService) { }
private metadataService: MetadataService,
private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
@ -136,19 +138,23 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
publicationStatus: new FormControl('', []),
language: new FormControl('', []),
});
this.cdRef.markForCheck();
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
this.metadataService.getAllPublicationStatus().subscribe(statuses => {
this.publicationStatuses = statuses;
this.cdRef.markForCheck();
});
this.metadataService.getAllValidLanguages().subscribe(validLanguages => {
this.validLanguages = validLanguages;
})
this.cdRef.markForCheck();
});
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
if (metadata) {
@ -159,38 +165,46 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.editSeriesForm.get('ageRating')?.patchValue(this.metadata.ageRating);
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
this.cdRef.markForCheck();
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.series.nameLocked = true;
this.cdRef.markForCheck();
});
this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.series.sortNameLocked = true;
this.cdRef.markForCheck();
});
this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.series.localizedNameLocked = true;
this.cdRef.markForCheck();
});
this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.summaryLocked = true;
this.metadata.summary = val;
this.cdRef.markForCheck();
});
this.editSeriesForm.get('ageRating')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.ageRating = parseInt(val + '', 10);
this.metadata.ageRatingLocked = true;
this.cdRef.markForCheck();
});
this.editSeriesForm.get('publicationStatus')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
this.metadata.publicationStatus = parseInt(val + '', 10);
this.metadata.publicationStatusLocked = true;
this.cdRef.markForCheck();
});
}
});
this.isLoadingVolumes = true;
this.cdRef.markForCheck();
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
this.seriesVolumes = volumes;
this.isLoadingVolumes = false;
@ -204,6 +218,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
return f;
})).flat();
});
this.cdRef.markForCheck();
});
}
@ -221,6 +236,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.setupLanguageTypeahead()
]).subscribe(results => {
this.collectionTags = this.metadata.collectionTags;
this.cdRef.markForCheck();
});
}
@ -441,8 +457,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.saveNestedComponents.emit();
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});
});
@ -450,16 +464,19 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
updateCollections(tags: CollectionTag[]) {
this.collectionTags = tags;
this.cdRef.markForCheck();
}
updateTags(tags: Tag[]) {
this.tags = tags;
this.metadata.tags = tags;
this.cdRef.markForCheck();
}
updateGenres(genres: Genre[]) {
this.genres = genres;
this.metadata.genres = genres;
this.cdRef.markForCheck();
}
updateLanguage(language: Array<Language>) {
@ -468,6 +485,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
return;
}
this.metadata.language = language[0].isoCode;
this.cdRef.markForCheck();
}
updatePerson(persons: Person[], role: PersonRole) {
@ -501,18 +519,20 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
break;
case PersonRole.Translator:
this.metadata.translators = persons;
}
this.cdRef.markForCheck();
}
updateSelectedIndex(index: number) {
this.editSeriesForm.patchValue({
coverImageIndex: index
});
this.cdRef.markForCheck();
}
updateSelectedImage(url: string) {
this.selectedCover = url;
this.cdRef.markForCheck();
}
handleReset() {
@ -520,12 +540,14 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.editSeriesForm.patchValue({
coverImageLocked: false
});
this.cdRef.markForCheck();
}
unlock(b: any, field: string) {
if (b) {
b[field] = !b[field];
}
this.cdRef.markForCheck();
}
}

View file

@ -1,8 +1,27 @@
<div *ngIf="bulkSelectionService.hasSelections()" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
<div class="d-flex justify-content-around align-items-center">
<span class="highlight"><i class="fa fa-check" aria-hidden="true"></i>&nbsp;{{bulkSelectionService.totalSelections()}} selected</span>
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
<button class="btn btn-icon" (click)="bulkSelectionService.deselectAll()"><i class="fa fa-times" aria-hidden="true"></i>&nbsp;Deselect All</button>
<ng-container *ngIf="bulkSelectionService.selections$ | async as selectionCount">
<div *ngIf="selectionCount > 0" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
<div class="d-flex justify-content-around align-items-center">
<span class="highlight">
<i class="fa fa-check me-1" aria-hidden="true"></i>
{{selectionCount}} items selected
</span>
<span>
<button *ngIf="hasMarkAsUnread" class="btn btn-icon" (click)="executeAction(Action.MarkAsUnread)" ngbTooltip="Mark as Unread" placement="bottom">
<i class="fa-regular fa-circle-check" aria-hidden="true"></i>
<span class="visually-hidden">Mark as Unread</span>
</button>
<button *ngIf="hasMarkAsRead" class="btn btn-icon" (click)="executeAction(Action.MarkAsRead)" ngbTooltip="Mark as Read" placement="bottom">
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
<span class="visually-hidden">Mark as Read</span>
</button>
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
</span>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
<button class="btn btn-icon" (click)="bulkSelectionService.deselectAll()"><i class="fa fa-times" aria-hidden="true"></i>&nbsp;Deselect All</button>
</div>
</div>
</div>
</ng-container>

View file

@ -1,28 +1,44 @@
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { BulkSelectionService } from '../bulk-selection.service';
@Component({
selector: 'app-bulk-operations',
templateUrl: './bulk-operations.component.html',
styleUrls: ['./bulk-operations.component.scss']
styleUrls: ['./bulk-operations.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BulkOperationsComponent implements OnInit {
export class BulkOperationsComponent implements OnInit, OnDestroy {
@Input() actionCallback!: (action: Action, data: any) => void;
topOffset: number = 0;
get actions() {
return this.bulkSelectionService.getActions(this.actionCallback.bind(this));
topOffset: number = 56;
hasMarkAsRead: boolean = false;
hasMarkAsUnread: boolean = false;
actions: Array<ActionItem<any>> = [];
private onDestory: Subject<void> = new Subject();
get Action() {
return Action;
}
constructor(public bulkSelectionService: BulkSelectionService) { }
constructor(public bulkSelectionService: BulkSelectionService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
const navBar = document.querySelector('.navbar');
if (navBar) {
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height); // TODO: We can make this fixed 63px
}
this.bulkSelectionService.actions$.pipe(takeUntil(this.onDestory)).subscribe(actions => {
actions.forEach(a => a.callback = this.actionCallback.bind(this));
this.actions = actions;
this.hasMarkAsRead = this.actions.filter(act => act.action === Action.MarkAsRead).length > 0;
this.hasMarkAsUnread = this.actions.filter(act => act.action === Action.MarkAsUnread).length > 0;
this.cdRef.markForCheck();
});
}
ngOnDestroy(): void {
this.onDestory.next();
this.onDestory.complete();
}
handleActionCallback(action: Action, data: any) {
@ -35,5 +51,10 @@ export class BulkOperationsComponent implements OnInit {
}
}
executeAction(action: Action) {
const foundActions = this.actions.filter(act => act.action === action);
if (foundActions.length > 0) {
this.performAction(foundActions[0]);
}
}
}

View file

@ -1,7 +1,8 @@
import { Injectable } from '@angular/core';
import { ChangeDetectorRef, Injectable } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
import { ReplaySubject } from 'rxjs';
import { filter } from 'rxjs/operators';
import { Action, ActionFactoryService } from '../_services/action-factory.service';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark';
@ -23,6 +24,15 @@ export class BulkSelectionService {
private dataSourceMax: { [key: string]: number} = {};
public isShiftDown: boolean = false;
private actionsSource = new ReplaySubject<ActionItem<any>[]>(1);
public actions$ = this.actionsSource.asObservable();
private selectionsSource = new ReplaySubject<number>(1);
/**
* Number of active selections
*/
public selections$ = this.selectionsSource.asObservable();
constructor(private router: Router, private actionFactory: ActionFactoryService) {
router.events
.pipe(filter(event => event instanceof NavigationStart))
@ -61,6 +71,7 @@ export class BulkSelectionService {
this.prevIndex = index;
this.prevDataSource = dataSource;
this.dataSourceMax[dataSource] = maxIndex;
this.actionsSource.next(this.getActions(() => {}));
}
isCardSelected(dataSource: DataSource, index: number) {
@ -77,6 +88,7 @@ export class BulkSelectionService {
if (from === to) {
this.selectedCards[dataSource][to] = value;
this.selectionsSource.next(this.totalSelections());
return;
}
@ -89,10 +101,12 @@ export class BulkSelectionService {
for (let i = from; i <= to; i++) {
this.selectedCards[dataSource][i] = value;
}
this.selectionsSource.next(this.totalSelections());
}
deselectAll() {
this.selectedCards = {};
this.selectionsSource.next(0);
}
hasSelections() {

View file

@ -95,7 +95,7 @@
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.Cover]">
<li [ngbNavItem]="tabs[TabID.Cover]" [disabled]="!(isAdmin$ | async)">
<a ngbNavLink>{{tabs[TabID.Cover].title}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls"
@ -115,7 +115,7 @@
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">

View file

@ -1,8 +1,8 @@
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { NgbActiveOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { finalize, Observable, take, takeWhile } from 'rxjs';
import { finalize, Observable, of, Subject, take, takeWhile, takeUntil, map, shareReplay } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
@ -33,9 +33,10 @@ enum TabID {
@Component({
selector: 'app-card-detail-drawer',
templateUrl: './card-detail-drawer.component.html',
styleUrls: ['./card-detail-drawer.component.scss']
styleUrls: ['./card-detail-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardDetailDrawerComponent implements OnInit {
export class CardDetailDrawerComponent implements OnInit, OnDestroy {
@Input() parentName = '';
@Input() seriesId: number = 0;
@ -55,6 +56,8 @@ export class CardDetailDrawerComponent implements OnInit {
*/
coverImageUrl!: string;
isAdmin$: Observable<boolean> = of(false);
actions: ActionItem<any>[] = [];
chapterActions: ActionItem<Chapter>[] = [];
@ -70,7 +73,7 @@ export class CardDetailDrawerComponent implements OnInit {
download$: Observable<Download> | null = null;
downloadInProgress: boolean = false;
private readonly onDestroy = new Subject<void>();
get MangaFormat() {
return MangaFormat;
@ -97,59 +100,56 @@ export class CardDetailDrawerComponent implements OnInit {
private accountService: AccountService, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private router: Router, private libraryService: LibraryService,
private seriesService: SeriesService, private readerService: ReaderService, public metadataService: MetadataService,
public activeOffcanvas: NgbActiveOffcanvas, private downloadService: DownloadService) { }
public activeOffcanvas: NgbActiveOffcanvas, private downloadService: DownloadService, private readonly cdRef: ChangeDetectorRef) {
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntil(this.onDestroy),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay()
);
}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.data);
this.chapter = this.utilityService.isChapter(this.data) ? (this.data as Chapter) : (this.data as Volume).chapters[0];
if (this.isChapter) {
this.coverImageUrl = this.imageService.getChapterCoverImage(this.data.id);
} else {
this.coverImageUrl = this.imageService.getVolumeCoverImage(this.data.id);
}
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.cdRef.markForCheck();
});
if (this.isChapter) {
this.coverImageUrl = this.imageService.getChapterCoverImage(this.data.id);
this.summary = this.utilityService.asChapter(this.data).summary || '';
this.chapters.push(this.data as Chapter);
} else {
this.coverImageUrl = this.imageService.getVolumeCoverImage(this.data.id);
this.summary = this.utilityService.asVolume(this.data).chapters[0].summary || '';
this.chapters.push(...(this.data as Volume).chapters);
}
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
if (!this.accountService.hasAdminRole(user)) {
this.tabs.find(s => s.title === 'Cover')!.disabled = true;
}
}
});
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
.filter(item => item.action !== Action.Edit);
this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false});
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
this.cdRef.markForCheck();
});
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit);
this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false});
if (this.isChapter) {
this.chapters.push(this.data as Chapter);
} else if (!this.isChapter) {
this.chapters.push(...(this.data as Volume).chapters);
}
// TODO: Move this into the backend
this.chapters.sort(this.utilityService.sortChapters);
this.chapters.forEach(c => c.coverImage = this.imageService.getChapterCoverImage(c.id));
// Try to show an approximation of the reading order for files
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
this.chapters.forEach((c: Chapter) => {
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
});
this.imageUrls = this.chapters.map(c => this.imageService.getChapterCoverImage(c.id));
this.cdRef.markForCheck();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
close() {
@ -184,7 +184,7 @@ export class CardDetailDrawerComponent implements OnInit {
return;
}
this.actionService.markChapterAsRead(this.seriesId, chapter, () => { /* No Action */ });
this.actionService.markChapterAsRead(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
markChapterAsUnread(chapter: Chapter) {
@ -192,7 +192,7 @@ export class CardDetailDrawerComponent implements OnInit {
return;
}
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { /* No Action */ });
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => { this.cdRef.markForCheck(); });
}
handleChapterActionCallback(action: Action, chapter: Chapter) {
@ -249,8 +249,10 @@ export class CardDetailDrawerComponent implements OnInit {
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
this.cdRef.markForCheck();
}));
this.download$.subscribe(() => {});
this.cdRef.markForCheck();
});
}

View file

@ -1,10 +1,11 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ActionItem } from 'src/app/_services/action-factory.service';
@Component({
selector: 'app-card-actionables',
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss']
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CardActionablesComponent implements OnInit {
@ -19,11 +20,12 @@ export class CardActionablesComponent implements OnInit {
nonAdminActions: ActionItem<any>[] = [];
constructor() { }
constructor(private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.nonAdminActions = this.actions.filter(item => !item.requiresAdmin);
this.adminActions = this.actions.filter(item => item.requiresAdmin);
this.cdRef.markForCheck();
}
preventClick(event: any) {

View file

@ -132,7 +132,8 @@ export class CardItemComponent implements OnInit, OnDestroy {
constructor(public imageService: ImageService, private libraryService: LibraryService,
public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService, public bulkSelectionService: BulkSelectionService,
private messageHub: MessageHubService, private accountService: AccountService, private scrollService: ScrollService, private changeDetectionRef: ChangeDetectorRef) {}
private messageHub: MessageHubService, private accountService: AccountService,
private scrollService: ScrollService, private readonly changeDetectionRef: ChangeDetectorRef) {}
ngOnInit(): void {
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
@ -143,6 +144,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
if (this.suppressLibraryLink === false) {
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
this.libraryId = (this.entity as Series).libraryId;
this.changeDetectionRef.markForCheck();
}
if (this.libraryId !== undefined && this.libraryId > 0) {
@ -175,7 +177,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
if (this.utilityService.isSeries(this.entity) && updateEvent.seriesId !== this.entity.id) return;
this.read = updateEvent.pagesRead;
this.changeDetectionRef.markForCheck();
this.changeDetectionRef.detectChanges();
});
}
@ -189,6 +191,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
if (!this.allowSelection) return;
this.selectionInProgress = false;
this.changeDetectionRef.markForCheck();
}
@HostListener('touchstart', ['$event'])
@ -304,5 +307,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
event.stopPropagation();
}
this.selection.emit(this.selected);
this.changeDetectionRef.detectChanges();
}
}

View file

@ -1,7 +1,6 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesCardComponent } from './series-card/series-card.component';
import { LibraryCardComponent } from './library-card/library-card.component';
import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
@ -14,12 +13,10 @@ import { SharedModule } from '../shared/shared.module';
import { RouterModule } from '@angular/router';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
import { BulkOperationsComponent } from './bulk-operations/bulk-operations.component';
import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/bulk-add-to-collection.component';
import { PipeModule } from '../pipe/pipe.module';
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
import { FileInfoComponent } from './file-info/file-info.component';
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component';
import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-drawer.component';
@ -36,17 +33,14 @@ import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.
declarations: [
CardItemComponent,
SeriesCardComponent,
LibraryCardComponent,
CoverImageChooserComponent,
EditSeriesModalComponent,
EditCollectionTagsComponent,
CardActionablesComponent,
CardDetailLayoutComponent,
CardDetailsModalComponent,
BulkOperationsComponent,
BulkAddToCollectionComponent,
ChapterMetadataDetailComponent,
FileInfoComponent,
EditSeriesRelationComponent,
CardDetailDrawerComponent,
EntityTitleComponent,
@ -89,15 +83,12 @@ import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.
exports: [
CardItemComponent,
SeriesCardComponent,
LibraryCardComponent,
SeriesCardComponent,
LibraryCardComponent,
CoverImageChooserComponent,
EditSeriesModalComponent,
EditCollectionTagsComponent,
CardActionablesComponent,
CardDetailLayoutComponent,
CardDetailsModalComponent,
BulkOperationsComponent,
ChapterMetadataDetailComponent,
EditSeriesRelationComponent,

View file

@ -1,43 +1,16 @@
import { Component, Input, OnInit } from '@angular/core';
import { Chapter } from 'src/app/_models/chapter';
import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { LibraryType } from 'src/app/_models/library';
import { ActionItem } from 'src/app/_services/action-factory.service';
import { PersonRole } from 'src/app/_models/person';
@Component({
selector: 'app-chapter-metadata-detail',
templateUrl: './chapter-metadata-detail.component.html',
styleUrls: ['./chapter-metadata-detail.component.scss']
styleUrls: ['./chapter-metadata-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChapterMetadataDetailComponent implements OnInit {
@Input() chapter: ChapterMetadata | undefined;
@Input() chapter!: ChapterMetadata;
@Input() libraryType: LibraryType = LibraryType.Manga;
constructor() { }
roles: string[] = [];
get LibraryType(): typeof LibraryType {
return LibraryType;
}
constructor(public utilityService: UtilityService) { }
ngOnInit(): void {
this.roles = Object.keys(PersonRole).filter(role => /[0-9]/.test(role) === false);
}
getPeople(role: string) {
if (this.chapter) {
return (this.chapter as any)[role.toLowerCase()];
}
return [];
}
performAction(action: ActionItem<Chapter>, chapter: Chapter) {
if (typeof action.callback === 'function') {
action.callback(action.action, chapter);
}
}
ngOnInit(): void {}
}

View file

@ -1,7 +1,7 @@
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
<form [formGroup]="form">
<ngx-file-drop (onFileDrop)="dropped($event)"
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" accept=".png,.jpg,.jpeg" [directory]="false" dropZoneClassName="file-upload" contentClassName="file-upload-zone" [directory]="false">
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" [accept]="acceptableExtensions" [directory]="false" dropZoneClassName="file-upload" contentClassName="file-upload-zone" [directory]="false">
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div class="row g-0 mt-3 pb-3" *ngIf="mode === 'all'">
<div class="mx-auto">

View file

@ -1,4 +1,4 @@
import { Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgxFileDropEntry, FileSystemFileEntry } from 'ngx-file-drop';
import { fromEvent, Subject } from 'rxjs';
@ -14,7 +14,8 @@ export type SelectCoverFunction = (selectedCover: string) => void;
@Component({
selector: 'app-cover-image-chooser',
templateUrl: './cover-image-chooser.component.html',
styleUrls: ['./cover-image-chooser.component.scss']
styleUrls: ['./cover-image-chooser.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CoverImageChooserComponent implements OnInit, OnDestroy {
@ -58,17 +59,19 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
appliedIndex: number = 0;
form!: FormGroup;
files: NgxFileDropEntry[] = [];
acceptableExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp'].join(',');
mode: 'file' | 'url' | 'all' = 'all';
private readonly onDestroy = new Subject<void>();
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService, private uploadService: UploadService,
@Inject(DOCUMENT) private document: Document) { }
@Inject(DOCUMENT) private document: Document, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.form = this.fb.group({
coverImageUrl: new FormControl('', [])
});
this.cdRef.markForCheck();
}
ngOnDestroy() {
@ -86,13 +89,14 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
}
ctx.drawImage(img, 0, 0);
var dataURL = canvas.toDataURL("image/png");
const dataURL = canvas.toDataURL("image/png");
return dataURL;
}
selectImage(index: number) {
if (this.selectedIndex === index) { return; }
this.selectedIndex = index;
this.cdRef.markForCheck();
this.imageSelected.emit(this.selectedIndex);
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
}
@ -101,6 +105,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
if (this.showApplyButton) {
this.applyCover.emit(this.imageUrls[index]);
this.appliedIndex = index;
this.cdRef.markForCheck();
}
}
@ -112,25 +117,27 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
loadImage() {
const url = this.form.get('coverImageUrl')?.value.trim();
if (url && url != '') {
if (!url && url === '') return;
this.uploadService.uploadByUrl(url).subscribe(filename => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = this.imageService.getCoverUploadImage(filename);
img.onload = (e) => this.handleUrlImageAdd(img);
img.onerror = (e) => {
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
this.form.get('coverImageUrl')?.setValue('');
};
this.uploadService.uploadByUrl(url).subscribe(filename => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = this.imageService.getCoverUploadImage(filename);
img.onload = (e) => this.handleUrlImageAdd(img);
img.onerror = (e) => {
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
this.form.get('coverImageUrl')?.setValue('');
});
}
this.cdRef.markForCheck();
};
this.form.get('coverImageUrl')?.setValue('');
this.cdRef.markForCheck();
});
}
changeMode(mode: 'url') {
this.mode = mode;
this.setupEnterHandler();
this.cdRef.markForCheck();
setTimeout(() => (this.document.querySelector('#load-image') as HTMLInputElement)?.focus(), 10);
}
@ -159,12 +166,14 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
this.selectedIndex += 1;
this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image
this.selectedBase64Url.emit(e.target.result);
this.cdRef.markForCheck();
}
handleUrlImageAdd(img: HTMLImageElement) {
const url = this.getBase64Image(img);
this.imageUrls.push(url);
this.imageUrlsChange.emit(this.imageUrls);
this.cdRef.markForCheck();
setTimeout(() => {
// Auto select newly uploaded image and tell parent of new base64 url

View file

@ -12,7 +12,7 @@
<form>
<div class="row g-0 mb-3" *ngFor="let relation of relations; let idx = index; let isLast = last;">
<div class="col-sm-12 col-md-7">
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings">
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings" id="relation--{{idx}}">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}} ({{libraryNames[item.libraryId]}})
</ng-template>
@ -23,6 +23,7 @@
</div>
<div class="col-sm-auto col-md-3">
<select class="form-select" [formControl]="relation.formControl">
<option [value]="RelationKind.Parent" disabled>Parent</option>
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.text}}</option>
</select>
</div>

View file

@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import { map, Subject, Observable, of, firstValueFrom, takeUntil, ReplaySubject } from 'rxjs';
import { UtilityService } from 'src/app/shared/_services/utility.service';
@ -19,7 +19,8 @@ interface RelationControl {
@Component({
selector: 'app-edit-series-relation',
templateUrl: './edit-series-relation.component.html',
styleUrls: ['./edit-series-relation.component.scss']
styleUrls: ['./edit-series-relation.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditSeriesRelationComponent implements OnInit, OnDestroy {
@ -30,18 +31,22 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
@Input() save: EventEmitter<void> = new EventEmitter();
@Output() saveApi = new ReplaySubject(1);
relationOptions = RelationKinds;
relationOptions = RelationKinds;
relations: Array<RelationControl> = [];
seriesSettings: TypeaheadSettings<SearchResult> = new TypeaheadSettings();
libraryNames: {[key:number]: string} = {};
get RelationKind() {
return RelationKind;
}
private onDestroy: Subject<void> = new Subject<void>();
constructor(private seriesService: SeriesService, private utilityService: UtilityService,
public imageService: ImageService, private libraryService: LibraryService) { }
public imageService: ImageService, private libraryService: LibraryService,
private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
this.seriesService.getRelatedForSeries(this.series.id).subscribe(async relations => {
@ -57,10 +62,12 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
this.setupRelationRows(relations.doujinshis, RelationKind.Doujinshi);
this.setupRelationRows(relations.contains, RelationKind.Contains);
this.setupRelationRows(relations.parent, RelationKind.Parent);
this.cdRef.detectChanges();
});
this.libraryService.getLibraryNames().subscribe(names => {
this.libraryNames = names;
this.cdRef.markForCheck();
});
this.save.pipe(takeUntil(this.onDestroy)).subscribe(() => this.saveState());
@ -74,27 +81,43 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
setupRelationRows(relations: Array<Series>, kind: RelationKind) {
relations.map(async item => {
const settings = await firstValueFrom(this.createSeriesTypeahead(item, kind));
return {series: item, typeaheadSettings: settings, formControl: new FormControl(kind, [])}
const form = new FormControl(kind, []);
if (kind === RelationKind.Parent) {
form.disable();
}
return {series: item, typeaheadSettings: settings, formControl: form};
}).forEach(async p => {
this.relations.push(await p);
this.cdRef.markForCheck();
});
}
async addNewRelation() {
this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation))});
this.cdRef.markForCheck();
// Focus on the new typeahead
setTimeout(() => {
const typeahead = document.querySelector(`#relation--${this.relations.length - 1} .typeahead-input input`) as HTMLInputElement;
if (typeahead) typeahead.focus();
}, 10);
}
removeRelation(index: number) {
this.relations.splice(index, 1);
this.cdRef.markForCheck();
}
updateSeries(event: Array<SearchResult | undefined>, relation: RelationControl) {
if (event[0] === undefined) {
relation.series = undefined;
this.cdRef.markForCheck();
return;
}
relation.series = {id: event[0].seriesId, name: event[0].name};
this.cdRef.markForCheck();
}
createSeriesTypeahead(series: Series | undefined, relationship: RelationKind): Observable<TypeaheadSettings<SearchResult>> {
@ -143,8 +166,6 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
const doujinshis = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Doujinshi && item.series !== undefined).map(item => item.series!.id);
// TODO: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin
//this.saveApi.next(this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis));
this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis).subscribe(() => {});
}

View file

@ -1,4 +1,4 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
@ -13,7 +13,8 @@ import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-entity-info-cards',
templateUrl: './entity-info-cards.component.html',
styleUrls: ['./entity-info-cards.component.scss']
styleUrls: ['./entity-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EntityInfoCardsComponent implements OnInit, OnDestroy {
@ -51,7 +52,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
return AgeRating;
}
constructor(private utilityService: UtilityService, private seriesService: SeriesService) {}
constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
@ -61,6 +62,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
if (this.includeMetadata) {
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.cdRef.markForCheck();
});
}
@ -86,11 +88,11 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
this.readingTime.maxHours = vol.maxHoursToRead;
this.readingTime.avgHours = vol.avgHoursToRead;
}
this.cdRef.markForCheck();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View file

@ -1,4 +1,4 @@
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library';
@ -7,7 +7,8 @@ import { Volume } from 'src/app/_models/volume';
@Component({
selector: 'app-entity-title',
templateUrl: './entity-title.component.html',
styleUrls: ['./entity-title.component.scss']
styleUrls: ['./entity-title.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EntityTitleComponent implements OnInit {
@ -27,7 +28,6 @@ export class EntityTitleComponent implements OnInit {
@Input() prioritizeTitleName: boolean = true;
isChapter = false;
chapter!: Chapter;
titleName: string = '';
volumeTitle: string = '';
@ -38,7 +38,7 @@ export class EntityTitleComponent implements OnInit {
constructor(private utilityService: UtilityService) {
constructor(private utilityService: UtilityService, private readonly cdRef: ChangeDetectorRef) {
}
ngOnInit(): void {
@ -53,5 +53,6 @@ export class EntityTitleComponent implements OnInit {
this.volumeTitle = v.name || '';
this.titleName = v.chapters[0].titleName || '';
}
this.cdRef.markForCheck();
}
}

View file

@ -1,11 +0,0 @@
<li class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
Pages: {{file.pages}}
</div>
<div class="col" *ngIf="created != undefined">
Added: {{(created | date: 'short') || '-'}}
</div>
</div>
</li>

View file

@ -1,25 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { MangaFile } from 'src/app/_models/manga-file';
@Component({
selector: 'app-file-info',
templateUrl: './file-info.component.html',
styleUrls: ['./file-info.component.scss']
})
export class FileInfoComponent implements OnInit {
/**
* MangaFile to display
*/
@Input() file!: MangaFile;
/**
* DateTime the entity this file belongs to was created
*/
@Input() created: string | undefined = undefined;
constructor() { }
ngOnInit(): void {
}
}

View file

@ -1,15 +0,0 @@
<ng-container *ngIf="data !== undefined">
<div class="card" style="width: 18rem;">
<div class="overlay" (click)="handleClick()">
<i class="fa {{icon}} card-img-top text-center" aria-hidden="true"></i>
<div class="card-actions">
<app-card-actionables [actions]="actions" [labelBy]="data.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event)"></app-card-actionables>
</div>
</div>
<div class="card-body text-center" *ngIf="data.name.length > 0 || actions.length > 0">
<span class="card-data.name" (click)="handleClick()">
{{data.name}}
</span>
</div>
</div>
</ng-container>

View file

@ -1,56 +0,0 @@
.card {
margin: 10px;
max-width: 160px;
cursor: pointer;
}
.card-title {
margin-top: 5px;
line-height: 20px;
font-size: 13px;
text-overflow: ellipsis;
white-space: normal;
max-width: 150px;
height: 52px;
padding-top: 10px;
}
.card-img-top {
height: 160px;
margin-top: 40% !important;
margin: auto;
font-size: 52px;
}
.overlay {
height: 160px;
&:hover {
visibility: visible;
.overlay-item {
visibility: visible;
}
}
.overlay-item {
visibility: hidden;
}
z-index: 10;
}
.card-actions {
position: absolute;
top: 125px;
right: -5px;
}
.card-body {
padding: 5px !important;
}
.dropdown-toggle:after {
content: none !important;
}

View file

@ -1,77 +0,0 @@
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import { Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { Library } from 'src/app/_models/library';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
// Represents a library type card.
@Component({
selector: 'app-library-card',
templateUrl: './library-card.component.html',
styleUrls: ['./library-card.component.scss']
})
export class LibraryCardComponent implements OnInit, OnChanges {
@Input() data!: Library;
@Output() clicked = new EventEmitter<Library>();
isAdmin = false;
actions: ActionItem<Library>[] = [];
icon = 'fa-book-open';
constructor(private accountService: AccountService, private router: Router,
private actionFactoryService: ActionFactoryService, private actionService: ActionService) {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
}
});
}
ngOnInit(): void {
}
ngOnChanges(changes: any) {
if (this.data) {
if (this.data.type === 0 || this.data.type === 1) {
this.icon = 'fa-book-open';
} else {
this.icon = 'fa-book';
}
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
}
}
handleAction(action: Action, library: Library) {
switch (action) {
case(Action.ScanLibrary):
this.actionService.scanLibrary(library);
break;
case(Action.RefreshMetadata):
this.actionService.refreshMetadata(library);
break;
default:
break;
}
}
performAction(action: ActionItem<Library>) {
if (typeof action.callback === 'function') {
action.callback(action.action, this.data);
}
}
handleClick() {
this.clicked.emit(this.data);
this.router.navigate(['library', this.data?.id]);
}
preventClick(event: any) {
event.stopPropagation();
event.preventDefault();
}
}

View file

@ -2,6 +2,8 @@ $image-height: 230px;
$image-width: 160px;
$triangle-size: 30px;
// with summary and cards, we have a height of 220px, we might want to default to 220px and let it grow from there to help with virtualization
.download {
width: 80px;
height: 80px;

View file

@ -1,12 +1,11 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
import { finalize, Observable, take, takeWhile } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library';
import { Series } from 'src/app/_models/series';
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
@ -14,7 +13,8 @@ import { Action, ActionItem } from 'src/app/_services/action-factory.service';
@Component({
selector: 'app-list-item',
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.scss']
styleUrls: ['./list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ListItemComponent implements OnInit {
@ -70,7 +70,6 @@ export class ListItemComponent implements OnInit {
@Output() read: EventEmitter<void> = new EventEmitter<void>();
actionInProgress: boolean = false;
summary$: Observable<string> = of('');
summary: string = '';
isChapter: boolean = false;
@ -84,16 +83,17 @@ export class ListItemComponent implements OnInit {
}
constructor(private utilityService: UtilityService, private downloadService: DownloadService, private toastr: ToastrService) { }
constructor(private utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
if (this.isChapter) {
this.summary = this.utilityService.asChapter(this.entity).summary || '';
} else {
this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || '';
}
this.cdRef.markForCheck();
}
performAction(action: ActionItem<any>) {
@ -109,6 +109,7 @@ export class ListItemComponent implements OnInit {
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.cdRef.markForCheck();
this.download$ = this.downloadService.downloadVolume(volume).pipe(
takeWhile(val => {
return val.state != 'DONE';
@ -116,7 +117,9 @@ export class ListItemComponent implements OnInit {
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
this.cdRef.markForCheck();
}));
this.cdRef.markForCheck();
});
} else if (this.utilityService.isChapter(this.entity)) {
const chapter = this.utilityService.asChapter(this.entity);
@ -124,6 +127,7 @@ export class ListItemComponent implements OnInit {
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.cdRef.markForCheck();
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
takeWhile(val => {
return val.state != 'DONE';
@ -131,7 +135,9 @@ export class ListItemComponent implements OnInit {
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
this.cdRef.markForCheck();
}));
this.cdRef.markForCheck();
});
}
return; // Don't propagate the download from a card

View file

@ -44,33 +44,28 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
*/
@Output() selection = new EventEmitter<boolean>();
isAdmin = false;
actions: ActionItem<Series>[] = [];
imageUrl: string = '';
onDestroy: Subject<void> = new Subject<void>();
constructor(private accountService: AccountService, private router: Router,
constructor(private router: Router, private cdRef: ChangeDetectorRef,
private seriesService: SeriesService, private toastr: ToastrService,
private modalService: NgbModal, private imageService: ImageService,
private actionFactoryService: ActionFactoryService,
private actionService: ActionService, private changeDetectionRef: ChangeDetectorRef) {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
}
});
}
private actionService: ActionService) {}
ngOnInit(): void {
if (this.data) {
this.imageUrl = this.imageService.getSeriesCoverImage(this.data.id);
this.cdRef.markForCheck();
}
}
ngOnChanges(changes: any) {
if (this.data) {
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
this.cdRef.markForCheck();
}
}
@ -120,7 +115,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
if (closeResult.success) {
this.seriesService.getSeries(data.id).subscribe(series => {
this.data = series;
this.changeDetectionRef.markForCheck();
this.cdRef.markForCheck();
this.reload.emit(true);
this.dataChanged.emit(series);
});
@ -150,7 +145,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
this.actionService.markSeriesAsUnread(series, () => {
if (this.data) {
this.data.pagesRead = 0;
this.changeDetectionRef.markForCheck();
this.cdRef.markForCheck();
}
this.dataChanged.emit(series);
@ -161,7 +156,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
this.actionService.markSeriesAsRead(series, () => {
if (this.data) {
this.data.pagesRead = series.pages;
this.changeDetectionRef.markForCheck();
this.cdRef.markForCheck();
}
this.dataChanged.emit(series);
});

View file

@ -1,19 +1,24 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core';
import { debounceTime, filter, map, Subject, takeUntil } from 'rxjs';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { UserProgressUpdateEvent } from 'src/app/_models/events/user-progress-update-event';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/series-metadata';
import { AccountService } from 'src/app/_services/account.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { MetadataService } from 'src/app/_services/metadata.service';
import { ReaderService } from 'src/app/_services/reader.service';
@Component({
selector: 'app-series-info-cards',
templateUrl: './series-info-cards.component.html',
styleUrls: ['./series-info-cards.component.scss']
styleUrls: ['./series-info-cards.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SeriesInfoCardsComponent implements OnInit {
export class SeriesInfoCardsComponent implements OnInit, OnChanges, OnDestroy {
@Input() series!: Series;
@Input() seriesMetadata!: SeriesMetadata;
@ -27,6 +32,8 @@ export class SeriesInfoCardsComponent implements OnInit {
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
private readonly onDestroy = new Subject<void>();
get MangaFormat() {
return MangaFormat;
}
@ -35,21 +42,50 @@ export class SeriesInfoCardsComponent implements OnInit {
return FilterQueryParam;
}
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private readerService: ReaderService) { }
constructor(public utilityService: UtilityService, public metadataService: MetadataService,
private readerService: ReaderService, private readonly cdRef: ChangeDetectorRef,
private messageHub: MessageHubService, private accountService: AccountService) {
// Listen for progress events and re-calculate getTimeLeft
this.messageHub.messages$.pipe(filter(event => event.event === EVENTS.UserProgressUpdate),
map(evt => evt.payload as UserProgressUpdateEvent),
debounceTime(500),
takeUntil(this.onDestroy))
.subscribe(updateEvent => {
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
if (user === undefined || user.username !== updateEvent.username) return;
if (updateEvent.seriesId !== this.series.id) return;
this.getReadingTimeLeft();
});
});
}
ngOnInit(): void {
if (this.series !== null) {
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft);
this.getReadingTimeLeft();
this.readingTime.minHours = this.series.minHoursToRead;
this.readingTime.maxHours = this.series.maxHoursToRead;
this.readingTime.avgHours = this.series.avgHoursToRead;
this.cdRef.markForCheck();
}
}
ngOnChanges() {
this.cdRef.markForCheck();
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
handleGoTo(queryParamName: FilterQueryParam, filter: any) {
this.goTo.emit({queryParamName, filter});
}
private getReadingTimeLeft() {
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => {
this.readingTimeLeft = timeLeft;
this.cdRef.markForCheck();
});
}
}