Custom Cover Images (#499)
* Added some documentation. Removed Require Admin Role from Search Tags. Added Summary to be updated on UpdateTag. * Added Swagger xml doc generation to beef up the documentation. Started adding xml comments to the APIs. This is a needed, slow task for upcoming Plugins system. * Implemented the ability to upload a custom series image to override the existing cover image. Refactored some code out to use ImageService and added more documentation * When a page cache fails, delete cache directory so user can try to reload. * Implemented the ability to lock a series cover image such that after user uploads something, it wont get refreshed by Kavita. * Implemented the ability to reset cover image for series by unlocking * Kick off a series refresh after a cover is unlocked. * Ability to press enter to load a url * Ability to reset selection * Cleaned up cover chooser such that reset is nicer, errors inform user to use file upload, series edit modal now doesn't use scrollable body. Mobile tweaks. CoverImageLocked is now sent to the UI. * More css changes to look better * When no bookmarks, don't show both markups * Fixed issues where images wouldn't refresh after cover image was changed. * Implemented the ability to change the cover images for collection tags. * Added property and API for chapter cover image update * Added UI code to prepare for updating cover image for chapters. need to rearrange components * Moved a ton of code around to separate card related screens into their own module. * Implemented the ability to update a chapter/volume cover image * Refactored action for volume to say edit to reflect modal action * Fixed issue where after editing chapter cover image, the underlying card wouldn't update * Fixed an issue where we were passing volumeId to the reset chapter lock. Changed some logic in volume cover image generation. * Automatically apply when you hit reset cover image
This commit is contained in:
parent
30387bc370
commit
2fd02f0d2b
95 changed files with 3364 additions and 20668 deletions
|
|
@ -0,0 +1,28 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}} Bookmarks</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<ul class="list-unstyled">
|
||||
<li class="list-group-item" *ngIf="bookmarks.length > 0">
|
||||
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="bookmarks.length === 0">
|
||||
No bookmarks yet
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
|
||||
<span *ngIf="isClearing" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Clear{{isClearing ? 'ing...' : ''}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="downloadBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
|
||||
<span *ngIf="isDownloading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Download{{isDownloading ? 'ing...' : ''}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler } from 'rxjs';
|
||||
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks-modal',
|
||||
templateUrl: './bookmarks-modal.component.html',
|
||||
styleUrls: ['./bookmarks-modal.component.scss']
|
||||
})
|
||||
export class BookmarksModalComponent implements OnInit {
|
||||
|
||||
@Input() series!: Series;
|
||||
|
||||
bookmarks: Array<PageBookmark> = [];
|
||||
title: string = '';
|
||||
subtitle: string = '';
|
||||
isDownloading: boolean = false;
|
||||
isClearing: boolean = false;
|
||||
|
||||
uniqueChapters: number = 0;
|
||||
|
||||
constructor(public imageService: ImageService, private readerService: ReaderService,
|
||||
public modal: NgbActiveModal, private downloadService: DownloadService,
|
||||
private toastr: ToastrService, private seriesService: SeriesService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.readerService.getBookmarksForSeries(this.series.id).pipe(take(1)).subscribe(bookmarks => {
|
||||
this.bookmarks = bookmarks;
|
||||
const chapters: {[id: number]: string} = {};
|
||||
this.bookmarks.forEach(bmk => {
|
||||
if (!chapters.hasOwnProperty(bmk.chapterId)) {
|
||||
chapters[bmk.chapterId] = '';
|
||||
}
|
||||
});
|
||||
this.uniqueChapters = Object.keys(chapters).length;
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
|
||||
downloadBookmarks() {
|
||||
this.isDownloading = true;
|
||||
this.downloadService.downloadBookmarks(this.bookmarks).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.isDownloading = false;
|
||||
})).subscribe(() => {/* No Operation */});
|
||||
}
|
||||
|
||||
clearBookmarks() {
|
||||
this.isClearing = true;
|
||||
this.readerService.clearBookmarks(this.series.id).subscribe(() => {
|
||||
this.isClearing = false;
|
||||
this.init();
|
||||
this.toastr.success(this.series.name + '\'s bookmarks have been removed');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
<div *ngIf="data !== undefined">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">
|
||||
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}} Details</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<h4 *ngIf="utilityService.isVolume(data)">Information</h4>
|
||||
|
||||
<ng-container *ngIf="utilityService.isVolume(data) || utilityService.isChapter(data)">
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Id: {{data.id}}
|
||||
</div>
|
||||
<div class="col">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters">
|
||||
<div class="col" *ngIf="utilityService.isVolume(data)">
|
||||
Added: {{(data.created | date: 'MM/dd/yyyy') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{data.pages}}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<h4 *ngIf="!utilityService.isChapter(data)">Chapters</h4>
|
||||
<ul class="list-unstyled">
|
||||
<li class="media my-4" *ngFor="let chapter of chapters">
|
||||
<img class="mr-3" style="width: 74px" src="{{imageService.randomize(imageService.getChapterCoverImage(chapter.id))}}">
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
Chapter {{formatChapterNumber(chapter)}}
|
||||
</span>
|
||||
<ng-template #specialHeader>File(s)</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let file of chapter.files" class="list-group-item">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-info" (click)="updateCover()">Update Cover</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.scrollable-modal {
|
||||
max-height: 90vh; // 600px
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { 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 { Volume } from 'src/app/_models/volume';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
|
||||
|
||||
|
||||
|
||||
@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() data!: any; // Volume | Chapter
|
||||
isChapter = false;
|
||||
chapters: Chapter[] = [];
|
||||
seriesVolumes: any[] = [];
|
||||
isLoadingVolumes = false;
|
||||
formatKeys = Object.keys(MangaFormat);
|
||||
/**
|
||||
* If a cover image update occured.
|
||||
*/
|
||||
coverImageUpdate: boolean = false;
|
||||
|
||||
|
||||
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.data);
|
||||
|
||||
if (this.isChapter) {
|
||||
this.chapters.push(this.data);
|
||||
} else if (!this.isChapter) {
|
||||
this.chapters.push(...this.data?.chapters);
|
||||
}
|
||||
this.chapters.sort(this.utilityService.sortChapters);
|
||||
// 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;
|
||||
}
|
||||
|
||||
updateCover() {
|
||||
const modalRef = this.modalService.open(ChangeCoverImageModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
|
||||
if (this.utilityService.isChapter(this.data)) {
|
||||
const chapter = this.utilityService.asChapter(this.data)
|
||||
modalRef.componentInstance.chapter = chapter;
|
||||
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : 'Chapter ') + chapter.range + '\'s Cover';
|
||||
} else {
|
||||
const volume = this.utilityService.asVolume(this.data);
|
||||
const chapters = volume.chapters;
|
||||
if (chapters && chapters.length > 0) {
|
||||
modalRef.componentInstance.chapter = chapters[0];
|
||||
modalRef.componentInstance.title = 'Select Volume ' + volume.number + '\'s Cover';
|
||||
}
|
||||
}
|
||||
|
||||
modalRef.closed.subscribe((closeResult: {success: boolean, chapter: Chapter, coverImageUpdate: boolean}) => {
|
||||
if (closeResult.success) {
|
||||
this.coverImageUpdate = closeResult.coverImageUpdate;
|
||||
if (!this.coverImageUpdate) {
|
||||
this.uploadService.resetChapterCoverLock(closeResult.chapter.id).subscribe(() => {
|
||||
this.toastr.info('Please refresh in a bit for the cover image to be reflected.');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<div class="modal-header">{{title}}</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p class="alert alert-primary" role="alert">
|
||||
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]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="loading">Save</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.scrollable-modal {
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-cover-image-modal',
|
||||
templateUrl: './change-cover-image-modal.component.html',
|
||||
styleUrls: ['./change-cover-image-modal.component.scss']
|
||||
})
|
||||
export class ChangeCoverImageModalComponent implements OnInit {
|
||||
|
||||
@Input() chapter!: Chapter;
|
||||
@Input() title: string = '';
|
||||
|
||||
selectedCover: string = '';
|
||||
imageUrls: Array<string> = [];
|
||||
coverImageIndex: number = 0;
|
||||
coverImageLocked: boolean = false;
|
||||
loading: boolean = false;
|
||||
|
||||
constructor(private imageService: ImageService, private uploadService: UploadService, public modal: NgbActiveModal) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// Randomization isn't needed as this is only the chooser
|
||||
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.modal.close({success: false, coverImageUpdate: false})
|
||||
}
|
||||
save() {
|
||||
this.loading = true;
|
||||
if (this.coverImageIndex > 0) {
|
||||
this.chapter.coverImageLocked = true;
|
||||
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
|
||||
if (this.coverImageIndex > 0) {
|
||||
this.chapter.coverImageLocked = true;
|
||||
}
|
||||
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
|
||||
this.loading = false;
|
||||
}, err => this.loading = false);
|
||||
} else {
|
||||
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.coverImageIndex = index;
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.coverImageLocked = false;
|
||||
this.chapter.coverImageLocked = false;
|
||||
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
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>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<form [formGroup]="collectionTagForm">
|
||||
<div class="form-group">
|
||||
<label for="summary">Summary</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<h6>Applies to Series</h6>
|
||||
<div class="form-check">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let item of series; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]">
|
||||
<a ngbNavLink>{{tabs[1]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
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>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { SelectionModel } from 'src/app/typeahead/typeahead.component';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-collection-tags',
|
||||
templateUrl: './edit-collection-tags.component.html',
|
||||
styleUrls: ['./edit-collection-tags.component.scss']
|
||||
})
|
||||
export class EditCollectionTagsComponent implements OnInit {
|
||||
|
||||
@Input() tag!: CollectionTag;
|
||||
series: Array<Series> = [];
|
||||
selections!: SelectionModel<Series>;
|
||||
isLoading: boolean = true;
|
||||
|
||||
pagination!: Pagination;
|
||||
selectAll: boolean = true;
|
||||
libraryNames!: any;
|
||||
collectionTagForm!: FormGroup;
|
||||
tabs = ['General', 'Cover Image'];
|
||||
active = this.tabs[0];
|
||||
imageUrls: Array<string> = [];
|
||||
selectedCover: string = '';
|
||||
|
||||
constructor(public modal: NgbActiveModal, private seriesService: SeriesService,
|
||||
private collectionService: CollectionTagService, private toastr: ToastrService,
|
||||
private confirmSerivce: ConfirmService, private libraryService: LibraryService,
|
||||
private imageService: ImageService, private uploadService: UploadService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.pagination == undefined) {
|
||||
this.pagination = {totalPages: 1, totalItems: 200, itemsPerPage: 200, currentPage: 0};
|
||||
}
|
||||
this.collectionTagForm = new FormGroup({
|
||||
summary: new FormControl(this.tag.summary, []),
|
||||
coverImageLocked: new FormControl(this.tag.coverImageLocked, []),
|
||||
coverImageIndex: new FormControl(0, []),
|
||||
|
||||
});
|
||||
this.imageUrls.push(this.imageService.randomize(this.imageService.getCollectionCoverImage(this.tag.id)));
|
||||
this.loadSeries();
|
||||
}
|
||||
|
||||
onPageChange(pageNum: number) {
|
||||
this.pagination.currentPage = pageNum;
|
||||
this.loadSeries();
|
||||
}
|
||||
|
||||
toggleAll() {
|
||||
this.selectAll = !this.selectAll;
|
||||
this.series.forEach(s => this.selections.toggle(s, this.selectAll));
|
||||
}
|
||||
|
||||
loadSeries() {
|
||||
forkJoin([
|
||||
this.seriesService.getSeriesForTag(this.tag.id, this.pagination.currentPage, this.pagination.itemsPerPage),
|
||||
this.libraryService.getLibraryNames()
|
||||
]).subscribe(results => {
|
||||
const series = results[0];
|
||||
|
||||
this.pagination = series.pagination;
|
||||
this.series = series.result;
|
||||
this.selections = new SelectionModel<Series>(true, this.series);
|
||||
this.isLoading = false;
|
||||
|
||||
this.libraryNames = results[1];
|
||||
});
|
||||
}
|
||||
|
||||
handleSelection(item: Series) {
|
||||
this.selections.toggle(item);
|
||||
const numberOfSelected = this.selections.selected().length;
|
||||
if (numberOfSelected == 0) {
|
||||
this.selectAll = false;
|
||||
} else if (numberOfSelected == this.series.length) {
|
||||
this.selectAll = true;
|
||||
}
|
||||
}
|
||||
|
||||
togglePromotion() {
|
||||
const originalPromotion = this.tag.promoted;
|
||||
this.tag.promoted = !this.tag.promoted;
|
||||
this.collectionService.updateTag(this.tag).subscribe(res => {
|
||||
this.toastr.success('Tag updated successfully');
|
||||
}, err => {
|
||||
this.tag.promoted = originalPromotion;
|
||||
});
|
||||
}
|
||||
|
||||
libraryName(libraryId: number) {
|
||||
return this.libraryNames[libraryId];
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close(false);
|
||||
}
|
||||
|
||||
async save() {
|
||||
const selectedIndex = this.collectionTagForm.get('coverImageIndex')?.value || 0;
|
||||
const unselectedIds = this.selections.unselected().map(s => s.id);
|
||||
const tag: CollectionTag = {...this.tag};
|
||||
tag.summary = this.collectionTagForm.get('summary')?.value;
|
||||
tag.coverImageLocked = this.collectionTagForm.get('coverImageLocked')?.value;
|
||||
|
||||
if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apis = [this.collectionService.updateTag(this.tag),
|
||||
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id))
|
||||
];
|
||||
|
||||
if (selectedIndex > 0) {
|
||||
apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover))
|
||||
}
|
||||
|
||||
forkJoin(apis).subscribe(results => {
|
||||
this.modal.close({success: true, coverImageUpdated: selectedIndex > 0});
|
||||
this.toastr.success('Tag updated');
|
||||
});
|
||||
}
|
||||
|
||||
get someSelected() {
|
||||
const selected = this.selections.selected();
|
||||
return (selected.length != this.series.length && selected.length != 0);
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.collectionTagForm.patchValue({
|
||||
coverImageIndex: index
|
||||
});
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.collectionTagForm.patchValue({
|
||||
coverImageLocked: false
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
<div *ngIf="series !== undefined">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">
|
||||
{{this.series.name}} Details</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<form [formGroup]="editSeriesForm">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="form-group" style="width: 100%">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" class="form-control" formControlName="name" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="form-group" style="width: 100%">
|
||||
<label for="sort-name">Sort Name</label>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="form-group" style="width: 100%">
|
||||
<label for="localized-name">Localized Name</label>
|
||||
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="author">Author</label>
|
||||
<input id="author" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="author" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="artist">Artist</label>
|
||||
<input id="artist" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="artist" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters" *ngIf="metadata">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="genres">Genres</label>
|
||||
<input id="genres" class="form-control" placeholder="Not Implemented" readonly="true" formControlName="genres" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="collections">Collections</label>
|
||||
<app-typeahead (selectedData)="updateCollections($event)" [settings]="settings">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="form-group" style="width: 100%">
|
||||
<label for="summary">Summary</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]">
|
||||
<a ngbNavLink>{{tabs[1]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>Not Yet implemented</p>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[2]">
|
||||
<a ngbNavLink>{{tabs[2]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
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]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[3]">
|
||||
<a ngbNavLink>{{tabs[3]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4>Information</h4>
|
||||
<div class="row no-gutters mb-2">
|
||||
<div class="col-md-6" *ngIf="libraryName">Library: {{libraryName | titlecase}}</div>
|
||||
<div class="col-md-6">Format: <app-tag-badge>{{utilityService.mangaFormat(series.format)}}</app-tag-badge></div>
|
||||
</div>
|
||||
<h4>Volumes</h4>
|
||||
<div class="spinner-border text-secondary" role="status" *ngIf="isLoadingVolumes">
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
|
||||
<li class="media my-4" *ngFor="let volume of seriesVolumes">
|
||||
<img class="mr-3" style="width: 74px;" src="{{imageService.getVolumeCoverImage(volume.id)}}" >
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
|
||||
<div>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Created: {{volume.created | date: 'MM/dd/yyyy'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Last Modified: {{volume.lastModified | date: 'MM/dd/yyyy'}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
<!-- Is Special: {{volume.isSpecial}} -->
|
||||
<button type="button" class="btn btn-outline-primary" (click)="collapse.toggle()" [attr.aria-expanded]="!volumeCollapsed[volume.name]">
|
||||
View Files
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{volume.pages}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div #collapse="ngbCollapse" [(ngbCollapse)]="volumeCollapsed[volume.name]">
|
||||
<ul class="list-group mt-2">
|
||||
<li *ngFor="let file of volume.volumeFiles.sort()" class="list-group-item">
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Chapter: {{file.chapter}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.scrollable-modal {
|
||||
max-height: 90vh; // 600px
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { SeriesMetadata } from 'src/app/_models/series-metadata';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-series-modal',
|
||||
templateUrl: './edit-series-modal.component.html',
|
||||
styleUrls: ['./edit-series-modal.component.scss']
|
||||
})
|
||||
export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() series!: Series;
|
||||
seriesVolumes: any[] = [];
|
||||
isLoadingVolumes = false;
|
||||
|
||||
isCollapsed = true;
|
||||
volumeCollapsed: any = {};
|
||||
tabs = ['General', 'Fix Match', 'Cover Image', 'Info'];
|
||||
active = this.tabs[0];
|
||||
editSeriesForm!: FormGroup;
|
||||
libraryName: string | undefined = undefined;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
settings: TypeaheadSettings<CollectionTag> = new TypeaheadSettings();
|
||||
tags: CollectionTag[] = [];
|
||||
metadata!: SeriesMetadata;
|
||||
imageUrls: Array<string> = [];
|
||||
/**
|
||||
* Selected Cover for uploading
|
||||
*/
|
||||
selectedCover: string = '';
|
||||
|
||||
constructor(public modal: NgbActiveModal,
|
||||
private seriesService: SeriesService,
|
||||
public utilityService: UtilityService,
|
||||
private fb: FormBuilder,
|
||||
public imageService: ImageService,
|
||||
private libraryService: LibraryService,
|
||||
private collectionService: CollectionTagService,
|
||||
private uploadService: UploadService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.imageUrls.push({
|
||||
// imageUrl: this.imageService.getSeriesCoverImage(this.series.id),
|
||||
// source: 'Url'
|
||||
// });
|
||||
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
|
||||
|
||||
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
|
||||
this.libraryName = names[this.series.libraryId];
|
||||
});
|
||||
|
||||
this.setupTypeaheadSettings();
|
||||
|
||||
|
||||
|
||||
this.editSeriesForm = this.fb.group({
|
||||
id: new FormControl(this.series.id, []),
|
||||
summary: new FormControl(this.series.summary, []),
|
||||
name: new FormControl(this.series.name, []),
|
||||
localizedName: new FormControl(this.series.localizedName, []),
|
||||
sortName: new FormControl(this.series.sortName, []),
|
||||
rating: new FormControl(this.series.userRating, []),
|
||||
|
||||
genres: new FormControl('', []),
|
||||
author: new FormControl('', []),
|
||||
artist: new FormControl('', []),
|
||||
|
||||
coverImageIndex: new FormControl(0, []),
|
||||
coverImageLocked: new FormControl(this.series.coverImageLocked, [])
|
||||
});
|
||||
|
||||
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
|
||||
if (metadata) {
|
||||
this.metadata = metadata;
|
||||
this.settings.savedData = metadata.tags;
|
||||
}
|
||||
});
|
||||
|
||||
this.isLoadingVolumes = true;
|
||||
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
|
||||
this.seriesVolumes = volumes;
|
||||
this.isLoadingVolumes = false;
|
||||
|
||||
volumes.forEach(v => {
|
||||
this.volumeCollapsed[v.name] = true;
|
||||
});
|
||||
this.seriesVolumes.forEach(vol => {
|
||||
vol.volumeFiles = vol.chapters?.sort(this.utilityService.sortChapters).map((c: Chapter) => c.files.map((f: any) => {
|
||||
f.chapter = c.number;
|
||||
return f;
|
||||
})).flat();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
setupTypeaheadSettings() {
|
||||
this.settings.minCharacters = 0;
|
||||
this.settings.multiple = true;
|
||||
this.settings.id = 'collections';
|
||||
this.settings.unique = true;
|
||||
this.settings.addIfNonExisting = true;
|
||||
this.settings.fetchFn = (filter: string) => this.fetchCollectionTags(filter);
|
||||
this.settings.addTransformFn = ((title: string) => {
|
||||
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
|
||||
});
|
||||
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
|
||||
const f = filter.toLowerCase();
|
||||
return options.filter(m => m.title.toLowerCase() === f);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close({success: false, series: undefined});
|
||||
}
|
||||
|
||||
fetchCollectionTags(filter: string = '') {
|
||||
return this.collectionService.search(filter);
|
||||
}
|
||||
|
||||
formatChapterNumber(chapter: Chapter) {
|
||||
if (chapter.number === '0') {
|
||||
return '1';
|
||||
}
|
||||
return chapter.number;
|
||||
}
|
||||
|
||||
save() {
|
||||
const model = this.editSeriesForm.value;
|
||||
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
|
||||
const apis = [
|
||||
this.seriesService.updateSeries(model),
|
||||
this.seriesService.updateMetadata(this.metadata, this.tags)
|
||||
];
|
||||
|
||||
if (selectedIndex > 0) {
|
||||
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
|
||||
}
|
||||
|
||||
forkJoin(apis).subscribe(results => {
|
||||
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});
|
||||
});
|
||||
}
|
||||
|
||||
updateCollections(tags: CollectionTag[]) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.editSeriesForm.patchValue({
|
||||
coverImageIndex: index
|
||||
});
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.editSeriesForm.patchValue({
|
||||
coverImageLocked: false
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<div class="container-fluid" style="padding-top: 10px">
|
||||
<div class="row no-gutters pb-2">
|
||||
<div class="col mr-auto">
|
||||
<h2 style="display: inline-block">
|
||||
<span *ngIf="actions.length > 0" class="">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
</span> {{header}}
|
||||
<!-- NOTE: On mobile the pill can eat up a lot of space, we can hide it and move to the filter section if user is interested -->
|
||||
<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button *ngIf="filters !== undefined && filters.length > 0" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="sr-only">Sort / Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row no-gutters filter-section" #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<div class="col">
|
||||
<form class="ml-2" [formGroup]="filterForm">
|
||||
<div class="form-group" *ngIf="filters.length > 0">
|
||||
<label for="series-filter">Filter</label>
|
||||
<select class="form-control" id="series-filter" formControlName="filter" (ngModelChange)="handleFilterChange($event)" style="max-width: 200px;">
|
||||
<option [value]="i" *ngFor="let opt of filters; let i = index">{{opt.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col-auto" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
There is no data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #paginationTemplate let-id="id">
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && items.length > 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[maxSize]="8"
|
||||
[rotate]="true"
|
||||
[ellipses]="false"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[collectionSize]="pagination.totalItems">
|
||||
|
||||
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
|
||||
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
|
||||
<div class="form-group d-flex flex-nowrap px-2">
|
||||
<label
|
||||
id="paginationInputLabel-{{id}}"
|
||||
for="paginationInput-{{id}}"
|
||||
class="col-form-label mr-2 ml-1"
|
||||
>Page</label>
|
||||
<input #i
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
class="form-control custom-pages-input"
|
||||
id="paginationInput-{{id}}"
|
||||
[value]="page"
|
||||
(keyup.enter)="selectPageStr(i.value)"
|
||||
(blur)="selectPageStr(i.value)"
|
||||
(input)="formatInput($any($event).target)"
|
||||
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
|
||||
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
|
||||
/>
|
||||
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
|
||||
of {{pagination.totalPages}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ng-template>
|
||||
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { FormGroup, FormControl } from '@angular/forms';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { FilterItem } from 'src/app/_models/series-filter';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
||||
const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
|
||||
export enum FilterAction {
|
||||
/**
|
||||
* If an option is selected on a multi select component
|
||||
*/
|
||||
Added = 0,
|
||||
/**
|
||||
* If an option is unselected on a multi select component
|
||||
*/
|
||||
Removed = 1,
|
||||
/**
|
||||
* If an option is selected on a single select component
|
||||
*/
|
||||
Selected = 2
|
||||
}
|
||||
|
||||
export interface UpdateFilterEvent {
|
||||
filterItem: FilterItem;
|
||||
action: FilterAction;
|
||||
}
|
||||
|
||||
const ANIMATION_SPEED = 300;
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss']
|
||||
})
|
||||
export class CardDetailLayoutComponent implements OnInit {
|
||||
|
||||
@Input() header: string = '';
|
||||
@Input() isLoading: boolean = false;
|
||||
@Input() items: any[] = [];
|
||||
@Input() pagination!: Pagination;
|
||||
/**
|
||||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
/**
|
||||
* A list of Filters which can filter the data of the page. If nothing is passed, the control will not show.
|
||||
*/
|
||||
@Input() filters: Array<FilterItem> = [];
|
||||
@Input() trackByIdentity!: (index: number, item: any) => string;
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<UpdateFilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
filterForm: FormGroup = new FormGroup({
|
||||
filter: new FormControl(0, []),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controls the visiblity of extended controls that sit below the main header.
|
||||
*/
|
||||
filteringCollapsed: boolean = true;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.filterForm.get('filter')?.value}_${item.id}_${index}`;
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.pageChange.emit(this.pagination);
|
||||
}
|
||||
|
||||
selectPageStr(page: string) {
|
||||
this.pagination.currentPage = parseInt(page, 10) || 1;
|
||||
this.onPageChange(this.pagination.currentPage);
|
||||
}
|
||||
|
||||
formatInput(input: HTMLInputElement) {
|
||||
input.value = input.value.replace(FILTER_PAG_REGEX, '');
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
handleFilterChange(index: string) {
|
||||
this.applyFilter.emit({
|
||||
filterItem: this.filters[parseInt(index, 10)],
|
||||
action: FilterAction.Selected
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<ng-container *ngIf="actions.length > 0">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
|
||||
<button ngbDropdownItem *ngFor="let action of nonAdminActions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
<div class="dropdown-divider" *ngIf="nonAdminActions.length > 1 && adminActions.length > 1"></div>
|
||||
<button ngbDropdownItem *ngFor="let action of adminActions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.dropdown-toggle:after {
|
||||
content: none !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-actionables',
|
||||
templateUrl: './card-actionables.component.html',
|
||||
styleUrls: ['./card-actionables.component.scss']
|
||||
})
|
||||
export class CardActionablesComponent implements OnInit {
|
||||
|
||||
@Input() iconClass = 'fa-ellipsis-v';
|
||||
@Input() btnClass = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() labelBy = 'card';
|
||||
@Input() disabled: boolean = false;
|
||||
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
|
||||
|
||||
adminActions: ActionItem<any>[] = [];
|
||||
nonAdminActions: ActionItem<any>[] = [];
|
||||
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.nonAdminActions = this.actions.filter(item => !item.requiresAdmin);
|
||||
this.adminActions = this.actions.filter(item => item.requiresAdmin);
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(event: any, action: ActionItem<any>) {
|
||||
this.preventClick(event);
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
this.actionHandler.emit(action);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Insert hr to separate admin actions
|
||||
|
||||
|
||||
}
|
||||
41
UI/Web/src/app/cards/card-item/card-item.component.html
Normal file
41
UI/Web/src/app/cards/card-item/card-item.component.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<div class="card">
|
||||
<div class="overlay" (click)="handleClick()">
|
||||
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
|
||||
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageService.errorImage" [attr.data-src]="imageUrl"
|
||||
aria-hidden="true" height="230px" width="158px">
|
||||
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="sr-only" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="error-banner" *ngIf="total === 0 && !supressArchiveWarning">
|
||||
Cannot Read Archive
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
|
||||
</div>
|
||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||
<div>
|
||||
<span class="card-title" placement="top" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
|
||||
<span *ngIf="isPromoted()">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
</span>
|
||||
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="sr-only">{{utilityService.mangaFormat(format)}}</span>
|
||||
{{title}}
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
<span class="card-actions float-right">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
</span>
|
||||
</div>
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!supressLibraryLink && libraryName">{{libraryName | titlecase}}</a>
|
||||
</div>
|
||||
</div>
|
||||
107
UI/Web/src/app/cards/card-item/card-item.component.scss
Normal file
107
UI/Web/src/app/cards/card-item/card-item.component.scss
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
@import '../../../theme/colors';
|
||||
|
||||
$triangle-size: 40px;
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.error-banner {
|
||||
width: 160px;
|
||||
height: 18px;
|
||||
background-color: $error-color;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 5px;
|
||||
max-width: $image-width;
|
||||
cursor: pointer;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
width: 130px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.card-img-top {
|
||||
height: $image-height;
|
||||
}
|
||||
|
||||
.progress-banner {
|
||||
width: 160px;
|
||||
height: 5px;
|
||||
|
||||
.progress {
|
||||
color: $primary-color;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 30%;
|
||||
}
|
||||
|
||||
.not-read-badge {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 $triangle-size $triangle-size 0;
|
||||
border-color: transparent $primary-color transparent transparent;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
height: $image-height;
|
||||
|
||||
&:hover {
|
||||
visibility: visible;
|
||||
|
||||
.overlay-item {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-item {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
position: absolute;
|
||||
top: 228px;
|
||||
right: 5px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 5px !important;
|
||||
}
|
||||
|
||||
.library {
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-top: 0px;
|
||||
}
|
||||
154
UI/Web/src/app/cards/card-item/card-item.component.ts
Normal file
154
UI/Web/src/app/cards/card-item/card-item.component.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler, Observable, Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
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 { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
templateUrl: './card-item.component.html',
|
||||
styleUrls: ['./card-item.component.scss']
|
||||
})
|
||||
export class CardItemComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() imageUrl = '';
|
||||
@Input() title = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() read = 0; // Pages read
|
||||
@Input() total = 0; // Total Pages
|
||||
@Input() supressLibraryLink = false;
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag; // This is the entity we are representing. It will be returned if an action is executed.
|
||||
@Output() clicked = new EventEmitter<string>();
|
||||
|
||||
libraryName: string | undefined = undefined; // Library name item belongs to
|
||||
libraryId: number | undefined = undefined;
|
||||
supressArchiveWarning: boolean = false; // This will supress the cannot read archive warning when total pages is 0
|
||||
format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public imageService: ImageService, private libraryService: LibraryService,
|
||||
public utilityService: UtilityService, private downloadService: DownloadService,
|
||||
private toastr: ToastrService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
|
||||
this.supressArchiveWarning = true;
|
||||
}
|
||||
|
||||
if (this.supressLibraryLink === false) {
|
||||
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
|
||||
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
|
||||
this.libraryId = (this.entity as Series).libraryId;
|
||||
this.libraryName = names[this.libraryId];
|
||||
}
|
||||
});
|
||||
}
|
||||
this.format = (this.entity as Series).format;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
isNullOrEmpty(val: string) {
|
||||
return val === null || val === undefined || val === '';
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
if (this.downloadInProgress === true) {
|
||||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.utilityService.isVolume(this.entity)) {
|
||||
const volume = this.utilityService.asVolume(this.entity);
|
||||
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadVolume(volume).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
} else if (this.utilityService.isChapter(this.entity)) {
|
||||
const chapter = this.utilityService.asChapter(this.entity);
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
} else if (this.utilityService.isSeries(this.entity)) {
|
||||
const series = this.utilityService.asSeries(this.entity);
|
||||
this.downloadService.downloadSeriesSize(series.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadSeries(series).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
}
|
||||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, this.entity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isPromoted() {
|
||||
const tag = this.entity as CollectionTag;
|
||||
return tag.hasOwnProperty('promoted') && tag.promoted;
|
||||
}
|
||||
}
|
||||
77
UI/Web/src/app/cards/cards.module.ts
Normal file
77
UI/Web/src/app/cards/cards.module.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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';
|
||||
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
|
||||
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { LazyLoadImageModule } from 'ng-lazyload-image';
|
||||
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgxFileDropModule } from 'ngx-file-drop';
|
||||
import { CardItemComponent } from './card-item/card-item.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TypeaheadModule } from '../typeahead/typeahead.module';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
|
||||
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CardItemComponent,
|
||||
SeriesCardComponent,
|
||||
LibraryCardComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
ChangeCoverImageModalComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
BrowserModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule, // EditCollectionsModal
|
||||
|
||||
SharedModule,
|
||||
TypeaheadModule,
|
||||
|
||||
NgbNavModule,
|
||||
NgbTooltipModule, // Card item
|
||||
//NgbAccordionModule,
|
||||
NgbCollapseModule,
|
||||
|
||||
NgbNavModule, //Series Detail
|
||||
LazyLoadImageModule,
|
||||
NgbPaginationModule, // CardDetailLayoutComponent
|
||||
NgbDropdownModule,
|
||||
NgbProgressbarModule,
|
||||
NgxFileDropModule, // Cover Chooser
|
||||
],
|
||||
exports: [
|
||||
CardItemComponent,
|
||||
SeriesCardComponent,
|
||||
LibraryCardComponent,
|
||||
SeriesCardComponent,
|
||||
LibraryCardComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
ChangeCoverImageModalComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent
|
||||
]
|
||||
})
|
||||
export class CardsModule { }
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<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">
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div class="row no-gutters mt-3 pb-3" *ngIf="mode === 'all'">
|
||||
<div class="mx-auto">
|
||||
<div class="row no-gutters mb-3">
|
||||
<i class="fa fa-file-upload mx-auto" style="font-size: 24px;" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="mx-auto">
|
||||
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="mode = 'url'; setupEnterHandler()"><span class="phone-hidden">Enter a </span>Url</a>
|
||||
<span class="col" style="padding-right:0px">•</span>
|
||||
<span class="col" style="padding-right:0px" href="javascript:void(0)">Drag and drop</span>
|
||||
<span class="col" style="padding-right:0px">•</span>
|
||||
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="openFileSelector()">Upload<span class="phone-hidden"> an image</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ng-container *ngIf="mode === 'url'">
|
||||
<div class="row no-gutters mt-3 pb-3 ml-md-2 mr-md-2">
|
||||
<div class="input-group col-md-10 mr-md-2" style="width: 100%">
|
||||
<div class="input-group-prepend">
|
||||
<label class="input-group-text" for="load-image">Url</label>
|
||||
</div>
|
||||
<input type="text" autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="col btn btn-secondary" href="javascript:void(0)" (click)="mode = 'all'" aria-label="Back">
|
||||
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>
|
||||
<span class="phone-hidden">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
|
||||
<ng-template>
|
||||
|
||||
</ng-template>
|
||||
</form>
|
||||
|
||||
<div class="row no-gutters chooser" style="padding-top: 10px">
|
||||
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
|
||||
<img class="card-img-top" [src]="url" aria-hidden="true" height="230px" width="158px" (error)="imageService.updateErroredImage($event)">
|
||||
</div>
|
||||
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
|
||||
<img class="card-img-top" title="Reset Cover Image" [src]="imageService.resetCoverImage" aria-hidden="true" height="230px" width="158px">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
@import '../../../theme/colors';
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.card-img-top {
|
||||
width: $image-width;
|
||||
height: $image-height;
|
||||
max-height: $image-height;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
outline: 5px solid $primary-color;
|
||||
outline-width: medium;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
ngx-file-drop ::ng-deep > div {
|
||||
// styling for the outer drop box
|
||||
width: 100%;
|
||||
border: 2px solid $primary-color;
|
||||
border-radius: 5px;
|
||||
height: 100px;
|
||||
margin: auto;
|
||||
|
||||
> div {
|
||||
// styling for the inner box (template)
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { Component, EventEmitter, 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';
|
||||
import { takeWhile } from 'rxjs/operators';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cover-image-chooser',
|
||||
templateUrl: './cover-image-chooser.component.html',
|
||||
styleUrls: ['./cover-image-chooser.component.scss']
|
||||
})
|
||||
export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() imageUrls: Array<string> = [];
|
||||
@Output() imageUrlsChange: EventEmitter<Array<string>> = new EventEmitter<Array<string>>();
|
||||
|
||||
/**
|
||||
* Should the control give the ability to select an image that emits the reset status for cover image
|
||||
*/
|
||||
@Input() showReset: boolean = false;
|
||||
@Output() resetClicked: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emits the selected index. Used usually to check if something other than the default image was selected.
|
||||
*/
|
||||
@Output() imageSelected: EventEmitter<number> = new EventEmitter<number>();
|
||||
/**
|
||||
* Emits a base64 encoded image
|
||||
*/
|
||||
@Output() selectedBase64Url: EventEmitter<string> = new EventEmitter<string>();
|
||||
|
||||
|
||||
|
||||
selectedIndex: number = 0;
|
||||
form!: FormGroup;
|
||||
files: NgxFileDropEntry[] = [];
|
||||
|
||||
mode: 'file' | 'url' | 'all' = 'all';
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
coverImageUrl: new FormControl('', [])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
getBase64Image(img: HTMLImageElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d", {alpha: false});
|
||||
if (!ctx) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
var dataURL = canvas.toDataURL("image/png");
|
||||
return dataURL;
|
||||
}
|
||||
|
||||
selectImage(index: number) {
|
||||
if (this.selectedIndex === index) { return; }
|
||||
this.selectedIndex = index;
|
||||
this.imageSelected.emit(this.selectedIndex);
|
||||
const selector = `.chooser img[src="${this.imageUrls[this.selectedIndex]}"]`;
|
||||
|
||||
|
||||
const elem = document.querySelector(selector) || document.querySelectorAll('.chooser img.card-img-top')[this.selectedIndex];
|
||||
if (elem) {
|
||||
const imageElem = <HTMLImageElement>elem;
|
||||
if (imageElem.src.startsWith('data')) {
|
||||
this.selectedBase64Url.emit(imageElem.src);
|
||||
return;
|
||||
}
|
||||
const image = this.getBase64Image(imageElem);
|
||||
if (image != '') {
|
||||
this.selectedBase64Url.emit(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
const url = this.form.get('coverImageUrl')?.value.trim();
|
||||
if (url && url != '') {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = this.form.get('coverImageUrl')?.value;
|
||||
img.onload = (e) => this.handleUrlImageAdd(e);
|
||||
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.form.get('coverImageUrl')?.setValue('');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.files = files;
|
||||
for (const droppedFile of files) {
|
||||
|
||||
// Is it a file?
|
||||
if (droppedFile.fileEntry.isFile) {
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
|
||||
fileEntry.file((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => this.handleFileImageAdd(e);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleFileImageAdd(e: any) {
|
||||
if (e.target == null) return;
|
||||
|
||||
this.imageUrls.push(e.target.result);
|
||||
this.imageUrlsChange.emit(this.imageUrls);
|
||||
this.selectedIndex += 1;
|
||||
this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image
|
||||
this.selectedBase64Url.emit(e.target.result);
|
||||
}
|
||||
|
||||
handleUrlImageAdd(e: any) {
|
||||
console.log(e);
|
||||
if (e.path === null || e.path.length === 0) return;
|
||||
|
||||
const url = this.getBase64Image(e.path[0]);
|
||||
this.imageUrls.push(url);
|
||||
this.imageUrlsChange.emit(this.imageUrls);
|
||||
|
||||
setTimeout(() => {
|
||||
// Auto select newly uploaded image and tell parent of new base64 url
|
||||
this.selectImage(this.selectedIndex + 1)
|
||||
});
|
||||
}
|
||||
|
||||
public fileOver(event: any){
|
||||
}
|
||||
|
||||
public fileLeave(event: any){
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.resetClicked.emit();
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
setupEnterHandler() {
|
||||
setTimeout(() => {
|
||||
const elem = document.querySelector('input[id="load-image"]');
|
||||
if (elem == null) return;
|
||||
fromEvent(elem, 'keydown')
|
||||
.pipe(takeWhile(() => this.mode === 'url')).subscribe((event) => {
|
||||
const evt = <KeyboardEvent>event;
|
||||
switch(evt.key) {
|
||||
case KEY_CODES.ENTER:
|
||||
{
|
||||
this.loadImage();
|
||||
break;
|
||||
}
|
||||
|
||||
case KEY_CODES.ESC_KEY:
|
||||
this.mode = 'all';
|
||||
event.stopPropagation();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
$primary-color: #cc7b19;
|
||||
|
||||
.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 {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
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;
|
||||
}
|
||||
77
UI/Web/src/app/cards/library-card/library-card.component.ts
Normal file
77
UI/Web/src/app/cards/library-card/library-card.component.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<ng-container *ngIf="data !== undefined">
|
||||
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl" [entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"></app-card-item>
|
||||
</ng-container>
|
||||
158
UI/Web/src/app/cards/series-card/series-card.component.ts
Normal file
158
UI/Web/src/app/cards/series-card/series-card.component.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ActionFactoryService, Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-card',
|
||||
templateUrl: './series-card.component.html',
|
||||
styleUrls: ['./series-card.component.scss']
|
||||
})
|
||||
export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
@Input() data: Series | undefined;
|
||||
@Input() libraryId = 0;
|
||||
@Input() suppressLibraryLink = false;
|
||||
@Output() clicked = new EventEmitter<Series>();
|
||||
@Output() reload = new EventEmitter<boolean>();
|
||||
@Output() dataChanged = new EventEmitter<Series>();
|
||||
|
||||
isAdmin = false;
|
||||
actions: ActionItem<Series>[] = [];
|
||||
imageUrl: string = '';
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router,
|
||||
private seriesService: SeriesService, private toastr: ToastrService,
|
||||
private modalService: NgbModal, private confirmService: ConfirmService,
|
||||
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.data) {
|
||||
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
|
||||
}
|
||||
}
|
||||
|
||||
ngOnChanges(changes: any) {
|
||||
if (this.data) {
|
||||
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
|
||||
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
|
||||
}
|
||||
}
|
||||
|
||||
handleSeriesActionCallback(action: Action, series: Series) {
|
||||
switch (action) {
|
||||
case(Action.MarkAsRead):
|
||||
this.markAsRead(series);
|
||||
break;
|
||||
case(Action.MarkAsUnread):
|
||||
this.markAsUnread(series);
|
||||
break;
|
||||
case(Action.ScanLibrary):
|
||||
this.scanLibrary(series);
|
||||
break;
|
||||
case(Action.RefreshMetadata):
|
||||
this.refreshMetdata(series);
|
||||
break;
|
||||
case(Action.Delete):
|
||||
this.deleteSeries(series);
|
||||
break;
|
||||
case(Action.Edit):
|
||||
this.openEditModal(series);
|
||||
break;
|
||||
case(Action.Bookmarks):
|
||||
this.actionService.openBookmarkModal(series, (series) => {/* No Operation */ });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
openEditModal(data: Series) {
|
||||
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' });
|
||||
modalRef.componentInstance.series = data;
|
||||
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
|
||||
window.scrollTo(0, 0);
|
||||
if (closeResult.success) {
|
||||
if (closeResult.coverImageUpdate) {
|
||||
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(closeResult.series.id));
|
||||
console.log('image url: ', this.imageUrl);
|
||||
}
|
||||
this.seriesService.getSeries(data.id).subscribe(series => {
|
||||
this.data = series;
|
||||
this.reload.emit(true);
|
||||
this.dataChanged.emit(series);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
refreshMetdata(series: Series) {
|
||||
this.seriesService.refreshMetadata(series).subscribe((res: any) => {
|
||||
this.toastr.success('Refresh started for ' + series.name);
|
||||
});
|
||||
}
|
||||
|
||||
scanLibrary(series: Series) {
|
||||
this.seriesService.scan(series.libraryId, series.id).subscribe((res: any) => {
|
||||
this.toastr.success('Scan started for ' + series.name);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSeries(series: Series) {
|
||||
if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.seriesService.delete(series.id).subscribe((res: boolean) => {
|
||||
if (res) {
|
||||
this.toastr.success('Series deleted');
|
||||
this.reload.emit(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markAsUnread(series: Series) {
|
||||
this.seriesService.markUnread(series.id).subscribe(res => {
|
||||
this.toastr.success(series.name + ' is now unread');
|
||||
series.pagesRead = 0;
|
||||
if (this.data) {
|
||||
this.data.pagesRead = 0;
|
||||
}
|
||||
|
||||
this.dataChanged.emit(series);
|
||||
});
|
||||
}
|
||||
|
||||
markAsRead(series: Series) {
|
||||
this.seriesService.markRead(series.id).subscribe(res => {
|
||||
this.toastr.success(series.name + ' is now read');
|
||||
series.pagesRead = series.pages;
|
||||
if (this.data) {
|
||||
this.data.pagesRead = series.pages;
|
||||
}
|
||||
this.dataChanged.emit(series);
|
||||
});
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.clicked.emit(this.data);
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.data?.id]);
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue