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:
Joseph Milazzo 2021-08-15 10:36:47 -07:00 committed by GitHub
parent 30387bc370
commit 2fd02f0d2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
95 changed files with 3364 additions and 20668 deletions

View file

@ -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">&times;</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>

View file

@ -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');
});
}
}

View file

@ -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">&times;</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>

View file

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

View file

@ -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.');
});
}
}
});
}
}

View file

@ -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>

View file

@ -0,0 +1,3 @@
.scrollable-modal {
}

View file

@ -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});
}
}

View file

@ -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">&times;</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>

View file

@ -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
});
}
}

View file

@ -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">&times;</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>

View file

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

View file

@ -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
});
}
}