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

@ -1,5 +1,8 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": "6b518972-3ce0-486d-bc55-740bf8308c77"
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
@ -41,7 +44,8 @@
],
"scripts": [
"node_modules/lazysizes/lazysizes.min.js",
"node_modules/lazysizes/plugins/rias/ls.rias.min.js"
"node_modules/lazysizes/plugins/rias/ls.rias.min.js",
"node_modules/lazysizes/plugins/attrchange/ls.attrchange.min.js"
]
},
"configurations": {

20349
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,7 @@
"ng-circle-progress": "^1.6.0",
"ng-lazyload-image": "^9.1.0",
"ng-sidebar": "^9.4.2",
"ngx-file-drop": "^11.1.0",
"ngx-toastr": "^13.2.1",
"rxjs": "~6.6.0",
"swiper": "^6.5.8",

View file

@ -1,53 +0,0 @@
<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>
<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>
</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

@ -5,7 +5,8 @@ export interface Chapter {
range: string;
number: string;
files: Array<MangaFile>;
coverImage: string;
//coverImage: string;
coverImageLocked: boolean;
pages: number;
volumeId: number;
pagesRead: number; // Attached for the given user when requesting from API

View file

@ -2,6 +2,10 @@ export interface CollectionTag {
id: number;
title: string;
promoted: boolean;
/**
* This is used as a placeholder to store the coverImage url. The backend does not use this or send it.
*/
coverImage: string;
coverImageLocked: boolean;
summary: string;
}

View file

@ -8,7 +8,8 @@ export interface Series {
localizedName: string;
sortName: string;
summary: string;
coverImage: string;
coverImage: string; // This is not passed from backend any longer. TODO: Remove this field
coverImageLocked: boolean;
volumes: Volume[];
pages: number; // Total pages in series
pagesRead: number; // Total pages the logged in user has read

View file

@ -104,6 +104,20 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: true
});
this.volumeActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: false
});
this.chapterActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: false
});
}
if (this.hasDownloadRole || this.isAdmin) {
@ -206,21 +220,5 @@ export class ActionFactoryService {
requiresAdmin: false
}
];
this.volumeActions.push({
action: Action.Info,
title: 'Info',
callback: this.dummyCallback,
requiresAdmin: false
});
this.chapterActions.push({
action: Action.Info,
title: 'Info',
callback: this.dummyCallback,
requiresAdmin: false
});
}
}

View file

@ -3,7 +3,7 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { BookmarksModalComponent } from '../_modals/bookmarks-modal/bookmarks-modal.component';
import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component';
import { Chapter } from '../_models/chapter';
import { Library } from '../_models/library';
import { Series } from '../_models/series';

View file

@ -16,14 +16,14 @@ export class CollectionTagService {
allTags() {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/').pipe(map(tags => {
tags.forEach(s => s.coverImage = this.imageService.getCollectionCoverImage(s.id));
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
return tags;
}));
}
search(query: string) {
return this.httpClient.get<CollectionTag[]>(this.baseUrl + 'collection/search?queryString=' + encodeURIComponent(query)).pipe(map(tags => {
tags.forEach(s => s.coverImage = this.imageService.getCollectionCoverImage(s.id));
tags.forEach(s => s.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(s.id)));
return tags;
}));
}

View file

@ -10,6 +10,7 @@ export class ImageService {
baseUrl = environment.apiUrl;
public placeholderImage = 'assets/images/image-placeholder-min.png';
public errorImage = 'assets/images/error-placeholder2-min.png';
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
constructor(private navSerivce: NavService) {
this.navSerivce.darkMode$.subscribe(res => {
@ -46,4 +47,17 @@ export class ImageService {
updateErroredImage(event: any) {
event.target.src = this.placeholderImage;
}
/**
* Used to refresh an existing loaded image (lazysizes). If random already attached, will append another number onto it.
* @param url Existing request url from ImageService only
* @returns Url with a random parameter attached
*/
randomize(url: string) {
const r = Math.random() * 100 + 1;
if (url.indexOf('&random') >= 0) {
return url + 1;
}
return url + '&random=' + r;
}
}

View file

@ -0,0 +1,43 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class UploadService {
private baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
/**
*
* @param seriesId Series to overwrite cover image for
* @param url A base64 encoded url
* @returns
*/
updateSeriesCoverImage(seriesId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/series', {id: seriesId, url: this._cleanBase64Url(url)});
}
updateCollectionCoverImage(tagId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/collection', {id: tagId, url: this._cleanBase64Url(url)});
}
updateChapterCoverImage(chapterId: number, url: string) {
return this.httpClient.post<number>(this.baseUrl + 'upload/chapter', {id: chapterId, url: this._cleanBase64Url(url)});
}
resetChapterCoverLock(chapterId: number, ) {
return this.httpClient.post<number>(this.baseUrl + 'upload/reset-chapter-lock', {id: chapterId, url: ''});
}
_cleanBase64Url(url: string) {
if (url.startsWith('data')) {
url = url.split(',')[1];
}
return url;
}
}

View file

@ -37,7 +37,7 @@ export class LibraryAccessModalComponent implements OnInit {
}
close() {
this.modal.close(false);
this.modal.dismiss();
}
save() {

View file

@ -74,10 +74,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
openEditLibraryAccess(member: Member) {
const modalRef = this.modalService.open(LibraryAccessModalComponent);
modalRef.componentInstance.member = member;
modalRef.closed.subscribe(result => {
if (result) {
this.loadMembers();
}
modalRef.closed.subscribe(() => {
this.loadMembers();
});
}

View file

@ -3,7 +3,7 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';

View file

@ -7,7 +7,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeComponent } from './home/home.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
@ -20,7 +20,6 @@ import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { CarouselModule } from './carousel/carousel.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
@ -35,12 +34,9 @@ import { Dedupe as DedupeIntegration } from '@sentry/integrations';
import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { TypeaheadModule } from './typeahead/typeahead.module';
import { AllCollectionsComponent } from './all-collections/all-collections.component';
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { LibraryCardComponent } from './library-card/library-card.component';
import { SeriesCardComponent } from './series-card/series-card.component';
import { InProgressComponent } from './in-progress/in-progress.component';
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
import { CardsModule } from './cards/cards.module';
let sentryProviders: any[] = [];
@ -98,16 +94,11 @@ if (environment.production) {
SeriesDetailComponent,
NotConnectedComponent, // Move into ExtrasModule
UserPreferencesComponent, // Move into SettingsModule
EditSeriesModalComponent,
ReviewSeriesModalComponent,
PersonBadgeComponent,
AllCollectionsComponent,
EditCollectionTagsComponent,
RecentlyAddedComponent,
LibraryCardComponent,
SeriesCardComponent,
InProgressComponent,
BookmarksModalComponent
],
imports: [
HttpClientModule,
@ -115,19 +106,23 @@ if (environment.production) {
AppRoutingModule,
BrowserAnimationsModule,
ReactiveFormsModule,
FormsModule, // EditCollection Modal
NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav
NgbTooltipModule, // Shared & SettingsModule
NgbRatingModule, // Series Detail
NgbCollapseModule, // Series Edit Modal
NgbNavModule, // Series Edit Modal
NgbNavModule,
NgbAccordionModule, // User Preferences
NgxSliderModule, // User Preference
NgbPaginationModule,
SharedModule,
CarouselModule,
TypeaheadModule,
FormsModule, // EditCollection Modal
CardsModule,
ToastrModule.forRoot({
positionClass: 'toast-bottom-right',
preventDuplicates: true,
@ -135,7 +130,6 @@ if (environment.production) {
countDuplicates: true,
autoDismiss: true
}),
],
providers: [
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},

View file

@ -7,7 +7,7 @@
<div class="modal-body">
<ul class="list-unstyled">
<li class="list-group-item">
<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">
@ -16,11 +16,11 @@
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) && bookmarks.length > 0">
<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">
<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>

View file

@ -7,19 +7,18 @@
</button>
</div>
<div class="modal-body scrollable-modal">
<h4 *ngIf="isObjectVolume(data)">Information</h4>
<h4 *ngIf="utilityService.isVolume(data)">Information</h4>
<ng-container *ngIf="isObjectVolume(data) || isObjectChapter(data)">
<ng-container *ngIf="utilityService.isVolume(data) || utilityService.isChapter(data)">
<div class="row no-gutters">
<div class="col">
Id: {{data.id}}
</div>
<div class="col">
<!-- Special: {{(data?.isSpecial ? 'Yes' : 'No') || 'N/A'}} -->
</div>
</div>
<div class="row no-gutters">
<div class="col" *ngIf="isObjectVolume(data)">
<div class="col" *ngIf="utilityService.isVolume(data)">
Added: {{(data.created | date: 'MM/dd/yyyy') || '-'}}
</div>
<div class="col">
@ -28,10 +27,10 @@
</div>
</ng-container>
<h4 *ngIf="!isObjectChapter(data)">Chapters</h4>
<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.getChapterCoverImage(chapter.id)}}">
<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">
@ -40,7 +39,7 @@
<ng-template #specialHeader>File(s)</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item"> <!-- .sort() -->
<li *ngFor="let file of chapter.files" class="list-group-item">
<span>{{file.filePath}}</span>
<div class="row no-gutters">
<div class="col">
@ -58,6 +57,7 @@
</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,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

@ -9,8 +9,10 @@ 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',
@ -28,11 +30,15 @@ export class EditCollectionTagsComponent implements OnInit {
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 confirmSerivce: ConfirmService, private libraryService: LibraryService,
private imageService: ImageService, private uploadService: UploadService) { }
ngOnInit(): void {
if (this.pagination == undefined) {
@ -40,7 +46,11 @@ export class EditCollectionTagsComponent implements OnInit {
}
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();
}
@ -99,17 +109,27 @@ export class EditCollectionTagsComponent implements OnInit {
}
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;
}
this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id)).subscribe(() => {
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');
this.modal.close(true);
});
}
@ -118,4 +138,20 @@ export class EditCollectionTagsComponent implements OnInit {
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

@ -89,8 +89,10 @@
<li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent>
<p>Not Yet implemented</p>
<img src="{{imageService.getSeriesCoverImage(series.id)}}">
<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]">

View file

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

View file

@ -13,6 +13,7 @@ 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',
@ -36,6 +37,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
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,
@ -43,9 +49,15 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
private fb: FormBuilder,
public imageService: ImageService,
private libraryService: LibraryService,
private collectionService: CollectionTagService) { }
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];
@ -67,7 +79,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
author: new FormControl('', []),
artist: new FormControl('', []),
coverImageIndex: new FormControl(0, [])
coverImageIndex: new FormControl(0, []),
coverImageLocked: new FormControl(this.series.coverImageLocked, [])
});
this.seriesService.getMetadata(this.series.id).subscribe(metadata => {
@ -107,7 +120,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
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: '' };
return {id: 0, title: title, promoted: false, coverImage: '', summary: '', coverImageLocked: false };
});
this.settings.compareFn = (options: CollectionTag[], filter: string) => {
const f = filter.toLowerCase();
@ -131,13 +144,19 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
}
save() {
// TODO: In future (once locking or metadata implemented), do a converstion to updateSeriesDto
forkJoin([
this.seriesService.updateSeries(this.editSeriesForm.value),
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)
]).subscribe(results => {
this.modal.close({success: true, series: this.editSeriesForm.value});
];
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});
});
}
@ -145,4 +164,20 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.tags = tags;
}
updateSelectedIndex(index: number) {
this.editSeriesForm.patchValue({
coverImageIndex: index
});
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.editSeriesForm.patchValue({
coverImageLocked: false
});
}
}

View file

@ -1,8 +1,8 @@
<div class="card">
<div class="overlay" (click)="handleClick()">
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.errorImage" [attr.data-src]="imageUrl"
<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>

View file

@ -2,6 +2,9 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angu
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';
@ -10,9 +13,6 @@ 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';
import { Download } from '../_models/download';
import { DownloadService } from '../_services/download.service';
import { UtilityService } from '../_services/utility.service';
@Component({
selector: 'app-card-item',
@ -44,7 +44,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(public imageSerivce: ImageService, private libraryService: LibraryService,
constructor(public imageService: ImageService, private libraryService: LibraryService,
public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService) {}
@ -62,8 +62,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
});
}
this.format = (this.entity as Series).format;
}
ngOnDestroy() {

View 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 { }

View file

@ -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>&nbsp;
<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>

View file

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

View file

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

View file

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

View file

@ -3,14 +3,14 @@ import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { EditSeriesModalComponent } from 'src/app/_modals/edit-series-modal/edit-series-modal.component';
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 '../shared/confirm.service';
import { ActionService } from '../_services/action.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',
@ -27,6 +27,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
isAdmin = false;
actions: ActionItem<Series>[] = [];
imageUrl: string = '';
constructor(private accountService: AccountService, private router: Router,
private seriesService: SeriesService, private toastr: ToastrService,
@ -42,11 +43,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
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));
}
}
@ -79,11 +84,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
}
openEditModal(data: Series) {
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg', scrollable: true });
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' });
modalRef.componentInstance.series = data;
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series}) => {
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);

View file

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router, ActivatedRoute } from '@angular/router';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, SeriesFilter, mangaFormatFilters } from '../_models/series-filter';

View file

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { Library } from '../_models/library';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';

View file

@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component';
import { EditCollectionTagsComponent } from '../cards/_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter';
import { Library } from '../_models/library';
@ -13,6 +13,7 @@ import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { CollectionTagService } from '../_services/collection-tag.service';
import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service';
import { SeriesService } from '../_services/series.service';
@ -41,7 +42,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
constructor(public accountService: AccountService, private libraryService: LibraryService,
private seriesService: SeriesService, private actionFactoryService: ActionFactoryService,
private collectionService: CollectionTagService, private router: Router,
private modalService: NgbModal, private titleService: Title) { }
private modalService: NgbModal, private titleService: Title, public imageService: ImageService) { }
ngOnInit(): void {
this.titleService.setTitle('Kavita - Dashboard');
@ -123,6 +124,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
if (reloadNeeded) {
// Reload tags
this.reloadTags();
collectionTag.coverImage = this.imageService.randomize(this.imageService.getCollectionCoverImage(collectionTag.id));
}
});
break;

View file

@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';

View file

@ -1,3 +0,0 @@
<ng-container *ngIf="data !== undefined">
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageService.getSeriesCoverImage(data.id)" [entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"></app-card-item>
</ng-container>

View file

@ -1,7 +1,7 @@
<div class="container-fluid" *ngIf="series !== undefined" style="padding-top: 10px">
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6">
<img class="poster lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageService.getSeriesCoverImage(series.id)"
<img class="poster lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="seriesImage"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true">
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
@ -118,12 +118,12 @@
<div class="row">
<div *ngFor="let volume of volumes">
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions"></app-card-item>
</div>
<div *ngFor="let chapter of chapters">
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
</div>
</div>

View file

@ -5,13 +5,13 @@ import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { asyncScheduler } from 'rxjs';
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service';
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
import { CardDetailsModalComponent } from '../shared/_modals/card-details-modal/card-details-modal.component';
import { DownloadService } from '../shared/_services/download.service';
import { UtilityService } from '../shared/_services/utility.service';
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
import { Chapter } from '../_models/chapter';
import { LibraryType } from '../_models/library';
@ -61,9 +61,17 @@ export class SeriesDetailComponent implements OnInit {
userReview: string = '';
libraryType: LibraryType = LibraryType.Manga;
seriesMetadata: SeriesMetadata | null = null;
/**
* Poster image for the Series
*/
seriesImage: string = '';
downloadInProgress: boolean = false;
/**
* Tricks the cover images for volume/chapter cards to update after we update one of them
*/
coverImageOffset: number = 0;
/**
* If an action is currently being done, don't let the user kick off another action
*/
@ -116,6 +124,7 @@ export class SeriesDetailComponent implements OnInit {
const seriesId = parseInt(routeId, 10);
this.libraryId = parseInt(libraryId, 10);
this.seriesImage = this.imageService.getSeriesCoverImage(seriesId);
this.loadSeriesMetadata(seriesId);
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type;
@ -169,7 +178,7 @@ export class SeriesDetailComponent implements OnInit {
case(Action.MarkAsUnread):
this.markAsUnread(volume);
break;
case(Action.Info):
case(Action.Edit):
this.openViewInfo(volume);
break;
default:
@ -185,7 +194,7 @@ export class SeriesDetailComponent implements OnInit {
case(Action.MarkAsUnread):
this.markChapterAsUnread(chapter);
break;
case(Action.Info):
case(Action.Edit):
this.openViewInfo(chapter);
break;
default:
@ -226,6 +235,7 @@ export class SeriesDetailComponent implements OnInit {
}
loadSeries(seriesId: number) {
this.coverImageOffset = 0;
this.seriesService.getMetadata(seriesId).subscribe(metadata => {
this.seriesMetadata = metadata;
});
@ -376,20 +386,29 @@ export class SeriesDetailComponent implements OnInit {
}
openViewInfo(data: Volume | Chapter) {
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg', scrollable: true });
const modalRef = this.modalService.open(CardDetailsModalComponent, { size: 'lg' }); // , scrollable: true (these don't work well on mobile)
modalRef.componentInstance.data = data;
modalRef.componentInstance.parentName = this.series?.name;
modalRef.componentInstance.seriesId = this.series?.id;
modalRef.closed.subscribe((result: {coverImageUpdate: boolean}) => {
if (result.coverImageUpdate) {
this.coverImageOffset += 1;
}
});
}
openEditSeriesModal() {
const modalRef = this.modalService.open(EditSeriesModalComponent, { scrollable: true, size: 'lg', windowClass: 'scrollable-modal' });
const modalRef = this.modalService.open(EditSeriesModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
modalRef.componentInstance.series = this.series;
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series}) => {
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
window.scrollTo(0, 0);
if (closeResult.success) {
this.loadSeries(this.series.id);
this.loadSeriesMetadata(this.series.id);
if (closeResult.coverImageUpdate) {
// Random triggers a load change without any problems with API
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id));
}
}
});
}

View file

@ -1,3 +0,0 @@
.scrollable-modal {
max-height: 600px;
}

View file

@ -1,65 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
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 { UtilityService } from '../../_services/utility.service';
@Component({
selector: 'app-card-details-modal',
templateUrl: './card-details-modal.component.html',
styleUrls: ['./card-details-modal.component.scss']
})
export class CardDetailsModalComponent implements OnInit {
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() data!: any; // Volume | Chapter
isChapter = false;
chapters: Chapter[] = [];
seriesVolumes: any[] = [];
isLoadingVolumes = false;
formatKeys = Object.keys(MangaFormat);
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
public imageService: ImageService) { }
ngOnInit(): void {
this.isChapter = this.isObjectChapter(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));
});
}
isObjectChapter(object: any): object is Chapter {
return ('files' in object);
}
isObjectVolume(object: any): object is Volume {
return !('originalName' in object) && !('files' in object);
}
close() {
this.modal.close();
}
formatChapterNumber(chapter: Chapter) {
if (chapter.number === '0') {
return '1';
}
return chapter.number;
}
}

View file

@ -11,7 +11,7 @@ import { Observable } from 'rxjs';
import { SAVER, Saver } from '../_providers/saver.provider';
import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { debounce, debounceTime, map, take } from 'rxjs/operators';
import { debounceTime } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
@ -40,10 +40,6 @@ export class DownloadService {
}
downloadLogs() {
// this.httpClient.get(this.baseUrl + 'server/logs', {observe: 'response', responseType: 'blob' as 'text'}).subscribe(resp => {
// this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, 'logs'));
// });
return this.httpClient.get(this.baseUrl + 'server/logs',
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => {
@ -63,7 +59,7 @@ export class DownloadService {
downloadChapter(chapter: Chapter) {
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => {
).pipe(debounceTime(300), download((blob, filename) => { //NOTE: DO I need debounceTime since I have throttleTime()?
this.save(blob, filename)
}));
}
@ -88,30 +84,6 @@ export class DownloadService {
}));
}
private preformSave(res: string, filename: string) {
const blob = new Blob([res], {type: 'text/plain;charset=utf-8'});
saveAs(blob, filename);
this.toastr.success('File downloaded successfully: ' + filename);
}
/**
* Attempts to parse out the filename from Content-Disposition header.
* If it fails, will default to defaultName and add the correct extension. If no extension is found in header, will use zip.
* @param headers
* @param defaultName
* @returns
*/
private getFilenameFromHeader(headers: HttpHeaders, defaultName: string) {
const tokens = (headers.get('content-disposition') || '').split(';');
let filename = tokens[1].replace('filename=', '').replace(/"/ig, '').trim();
if (filename.startsWith('download_') || filename.startsWith('kavita_download_')) {
const ext = filename.substring(filename.lastIndexOf('.'), filename.length);
return defaultName + ext;
}
return filename;
}
/**
* Format bytes as human-readable text.
*

View file

@ -1,19 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { CardItemComponent } from './card-item/card-item.component';
import { NgbCollapseModule, NgbDropdownModule, NgbPaginationModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { SafeHtmlPipe } from './safe-html.pipe';
import { LazyLoadImageModule } from 'ng-lazyload-image';
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
import { RegisterMemberComponent } from '../register-member/register-member.component';
import { ReadMoreComponent } from './read-more/read-more.component';
import { RouterModule } from '@angular/router';
import { DrawerComponent } from './drawer/drawer.component';
import { TagBadgeComponent } from './tag-badge/tag-badge.component';
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
import { A11yClickDirective } from './a11y-click.directive';
import { SeriesFormatComponent } from './series-format/series-format.component';
@ -25,45 +20,40 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
@NgModule({
declarations: [
RegisterMemberComponent,
CardItemComponent,
CardDetailsModalComponent,
ConfirmDialogComponent,
SafeHtmlPipe,
CardActionablesComponent,
ReadMoreComponent,
DrawerComponent,
TagBadgeComponent,
CardDetailLayoutComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent,
UpdateNotificationModalComponent,
CircularLoaderComponent
CircularLoaderComponent,
],
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
NgbDropdownModule,
NgbProgressbarModule,
NgbTooltipModule,
//NgbDropdownModule,
//NgbProgressbarModule,
//NgbTooltipModule,
NgbCollapseModule,
LazyLoadImageModule,
NgbPaginationModule, // CardDetailLayoutComponent
NgCircleProgressModule.forRoot()
//LazyLoadImageModule,
NgCircleProgressModule.forRoot(),
],
exports: [
RegisterMemberComponent,
CardItemComponent,
SafeHtmlPipe,
CardActionablesComponent,
ReadMoreComponent,
DrawerComponent,
TagBadgeComponent,
CardDetailLayoutComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent,
SeriesFormatComponent,
TagBadgeComponent,
CircularLoaderComponent,
],
providers: [{provide: SAVER, useFactory: getSaver}]
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -1,4 +1,4 @@
$primary-color: #4ac694; //#cc7b19;
$primary-color: #4ac694; //(74,198,148)
$error-color: #ff4136; // #bb2929 good color for contrast rating
$theme-colors: (