Custom Cover Images (#499)
* Added some documentation. Removed Require Admin Role from Search Tags. Added Summary to be updated on UpdateTag. * Added Swagger xml doc generation to beef up the documentation. Started adding xml comments to the APIs. This is a needed, slow task for upcoming Plugins system. * Implemented the ability to upload a custom series image to override the existing cover image. Refactored some code out to use ImageService and added more documentation * When a page cache fails, delete cache directory so user can try to reload. * Implemented the ability to lock a series cover image such that after user uploads something, it wont get refreshed by Kavita. * Implemented the ability to reset cover image for series by unlocking * Kick off a series refresh after a cover is unlocked. * Ability to press enter to load a url * Ability to reset selection * Cleaned up cover chooser such that reset is nicer, errors inform user to use file upload, series edit modal now doesn't use scrollable body. Mobile tweaks. CoverImageLocked is now sent to the UI. * More css changes to look better * When no bookmarks, don't show both markups * Fixed issues where images wouldn't refresh after cover image was changed. * Implemented the ability to change the cover images for collection tags. * Added property and API for chapter cover image update * Added UI code to prepare for updating cover image for chapters. need to rearrange components * Moved a ton of code around to separate card related screens into their own module. * Implemented the ability to update a chapter/volume cover image * Refactored action for volume to say edit to reflect modal action * Fixed issue where after editing chapter cover image, the underlying card wouldn't update * Fixed an issue where we were passing volumeId to the reset chapter lock. Changed some logic in volume cover image generation. * Automatically apply when you hit reset cover image
This commit is contained in:
parent
30387bc370
commit
2fd02f0d2b
95 changed files with 3364 additions and 20668 deletions
|
@ -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
20349
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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">×</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>
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
43
UI/Web/src/app/_services/upload.service.ts
Normal file
43
UI/Web/src/app/_services/upload.service.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -37,7 +37,7 @@ export class LibraryAccessModalComponent implements OnInit {
|
|||
}
|
||||
|
||||
close() {
|
||||
this.modal.close(false);
|
||||
this.modal.dismiss();
|
||||
}
|
||||
|
||||
save() {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
.scrollable-modal {
|
||||
max-height: 90vh; // 600px
|
||||
overflow: auto;
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { MangaFile } from 'src/app/_models/manga-file';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component';
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-details-modal',
|
||||
templateUrl: './card-details-modal.component.html',
|
||||
styleUrls: ['./card-details-modal.component.scss']
|
||||
})
|
||||
export class CardDetailsModalComponent implements OnInit {
|
||||
|
||||
@Input() parentName = '';
|
||||
@Input() seriesId: number = 0;
|
||||
@Input() data!: any; // Volume | Chapter
|
||||
isChapter = false;
|
||||
chapters: Chapter[] = [];
|
||||
seriesVolumes: any[] = [];
|
||||
isLoadingVolumes = false;
|
||||
formatKeys = Object.keys(MangaFormat);
|
||||
/**
|
||||
* If a cover image update occured.
|
||||
*/
|
||||
coverImageUpdate: boolean = false;
|
||||
|
||||
|
||||
constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService,
|
||||
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isChapter = this.utilityService.isChapter(this.data);
|
||||
|
||||
if (this.isChapter) {
|
||||
this.chapters.push(this.data);
|
||||
} else if (!this.isChapter) {
|
||||
this.chapters.push(...this.data?.chapters);
|
||||
}
|
||||
this.chapters.sort(this.utilityService.sortChapters);
|
||||
// Try to show an approximation of the reading order for files
|
||||
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
||||
this.chapters.forEach((c: Chapter) => {
|
||||
c.files.sort((a: MangaFile, b: MangaFile) => collator.compare(a.filePath, b.filePath));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close({coverImageUpdate: this.coverImageUpdate});
|
||||
}
|
||||
|
||||
formatChapterNumber(chapter: Chapter) {
|
||||
if (chapter.number === '0') {
|
||||
return '1';
|
||||
}
|
||||
return chapter.number;
|
||||
}
|
||||
|
||||
updateCover() {
|
||||
const modalRef = this.modalService.open(ChangeCoverImageModalComponent, { size: 'lg' }); // scrollable: true, size: 'lg', windowClass: 'scrollable-modal' (these don't work well on mobile)
|
||||
if (this.utilityService.isChapter(this.data)) {
|
||||
const chapter = this.utilityService.asChapter(this.data)
|
||||
modalRef.componentInstance.chapter = chapter;
|
||||
modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : 'Chapter ') + chapter.range + '\'s Cover';
|
||||
} else {
|
||||
const volume = this.utilityService.asVolume(this.data);
|
||||
const chapters = volume.chapters;
|
||||
if (chapters && chapters.length > 0) {
|
||||
modalRef.componentInstance.chapter = chapters[0];
|
||||
modalRef.componentInstance.title = 'Select Volume ' + volume.number + '\'s Cover';
|
||||
}
|
||||
}
|
||||
|
||||
modalRef.closed.subscribe((closeResult: {success: boolean, chapter: Chapter, coverImageUpdate: boolean}) => {
|
||||
if (closeResult.success) {
|
||||
this.coverImageUpdate = closeResult.coverImageUpdate;
|
||||
if (!this.coverImageUpdate) {
|
||||
this.uploadService.resetChapterCoverLock(closeResult.chapter.id).subscribe(() => {
|
||||
this.toastr.info('Please refresh in a bit for the cover image to be reflected.');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
<div class="modal-header">{{title}}</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<p class="alert alert-primary" role="alert">
|
||||
Upload and choose a new cover image. Press Save to upload and override the cover.
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="cancel()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="loading">Save</button>
|
||||
</div>
|
|
@ -1,3 +1,3 @@
|
|||
.scrollable-modal {
|
||||
height: 600px;
|
||||
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { UploadService } from 'src/app/_services/upload.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-cover-image-modal',
|
||||
templateUrl: './change-cover-image-modal.component.html',
|
||||
styleUrls: ['./change-cover-image-modal.component.scss']
|
||||
})
|
||||
export class ChangeCoverImageModalComponent implements OnInit {
|
||||
|
||||
@Input() chapter!: Chapter;
|
||||
@Input() title: string = '';
|
||||
|
||||
selectedCover: string = '';
|
||||
imageUrls: Array<string> = [];
|
||||
coverImageIndex: number = 0;
|
||||
coverImageLocked: boolean = false;
|
||||
loading: boolean = false;
|
||||
|
||||
constructor(private imageService: ImageService, private uploadService: UploadService, public modal: NgbActiveModal) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// Randomization isn't needed as this is only the chooser
|
||||
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.modal.close({success: false, coverImageUpdate: false})
|
||||
}
|
||||
save() {
|
||||
this.loading = true;
|
||||
if (this.coverImageIndex > 0) {
|
||||
this.chapter.coverImageLocked = true;
|
||||
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
|
||||
if (this.coverImageIndex > 0) {
|
||||
this.chapter.coverImageLocked = true;
|
||||
}
|
||||
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
|
||||
this.loading = false;
|
||||
}, err => this.loading = false);
|
||||
} else {
|
||||
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
updateSelectedIndex(index: number) {
|
||||
this.coverImageIndex = index;
|
||||
}
|
||||
|
||||
updateSelectedImage(url: string) {
|
||||
this.selectedCover = url;
|
||||
}
|
||||
|
||||
handleReset() {
|
||||
this.coverImageLocked = false;
|
||||
this.chapter.coverImageLocked = false;
|
||||
this.modal.close({success: true, chapter: this.chapter, coverImageUpdate: this.chapter.coverImageLocked});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">Edit {{tag?.title}} Collection</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
This tag is currently {{tag?.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
|
||||
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
|
||||
</p>
|
||||
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
|
||||
<li [ngbNavItem]="tabs[0]">
|
||||
<a ngbNavLink>{{tabs[0]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<form [formGroup]="collectionTagForm">
|
||||
<div class="form-group">
|
||||
<label for="summary">Summary</label>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="list-group" *ngIf="!isLoading">
|
||||
<h6>Applies to Series</h6>
|
||||
<div class="form-check">
|
||||
<input id="selectall" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="someSelected">
|
||||
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
|
||||
</div>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngFor="let item of series; let i = index">
|
||||
<div class="form-check">
|
||||
<input id="series-{{i}}" type="checkbox" class="form-check-input"
|
||||
[ngModel]="selections.isSelected(item)" (change)="handleSelection(item)">
|
||||
<label attr.for="series-{{i}}" class="form-check-label">{{item.name}} ({{libraryName(item.libraryId)}})</label>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
|
||||
[collectionSize]="pagination.totalItems"></ngb-pagination>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]">
|
||||
<a ngbNavLink>{{tabs[1]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
Upload and choose a new cover image. Press Save to upload and override the cover.
|
||||
</p>
|
||||
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
|
||||
<button type="button" class="btn btn-info" (click)="togglePromotion()">{{tag?.promoted ? 'Demote' : 'Promote'}}</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
|
||||
</div>
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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]">
|
|
@ -0,0 +1,4 @@
|
|||
.scrollable-modal {
|
||||
max-height: 90vh; // 600px
|
||||
overflow: auto;
|
||||
}
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
|
@ -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() {
|
77
UI/Web/src/app/cards/cards.module.ts
Normal file
77
UI/Web/src/app/cards/cards.module.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SeriesCardComponent } from './series-card/series-card.component';
|
||||
import { LibraryCardComponent } from './library-card/library-card.component';
|
||||
import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component';
|
||||
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
|
||||
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component';
|
||||
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { LazyLoadImageModule } from 'ng-lazyload-image';
|
||||
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { NgxFileDropModule } from 'ngx-file-drop';
|
||||
import { CardItemComponent } from './card-item/card-item.component';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { TypeaheadModule } from '../typeahead/typeahead.module';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
|
||||
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CardItemComponent,
|
||||
SeriesCardComponent,
|
||||
LibraryCardComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
ChangeCoverImageModalComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
BrowserModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
FormsModule, // EditCollectionsModal
|
||||
|
||||
SharedModule,
|
||||
TypeaheadModule,
|
||||
|
||||
NgbNavModule,
|
||||
NgbTooltipModule, // Card item
|
||||
//NgbAccordionModule,
|
||||
NgbCollapseModule,
|
||||
|
||||
NgbNavModule, //Series Detail
|
||||
LazyLoadImageModule,
|
||||
NgbPaginationModule, // CardDetailLayoutComponent
|
||||
NgbDropdownModule,
|
||||
NgbProgressbarModule,
|
||||
NgxFileDropModule, // Cover Chooser
|
||||
],
|
||||
exports: [
|
||||
CardItemComponent,
|
||||
SeriesCardComponent,
|
||||
LibraryCardComponent,
|
||||
SeriesCardComponent,
|
||||
LibraryCardComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
ChangeCoverImageModalComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent
|
||||
]
|
||||
})
|
||||
export class CardsModule { }
|
|
@ -0,0 +1,64 @@
|
|||
<div class="container-fluid" style="padding-left: 0px; padding-right: 0px">
|
||||
<form [formGroup]="form">
|
||||
<ngx-file-drop (onFileDrop)="dropped($event)"
|
||||
(onFileOver)="fileOver($event)" (onFileLeave)="fileLeave($event)" accept=".png,.jpg,.jpeg" [directory]="false" dropZoneClassName="file-upload" contentClassName="file-upload-zone" [directory]="false">
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div class="row no-gutters mt-3 pb-3" *ngIf="mode === 'all'">
|
||||
<div class="mx-auto">
|
||||
<div class="row no-gutters mb-3">
|
||||
<i class="fa fa-file-upload mx-auto" style="font-size: 24px;" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="mx-auto">
|
||||
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="mode = 'url'; setupEnterHandler()"><span class="phone-hidden">Enter a </span>Url</a>
|
||||
<span class="col" style="padding-right:0px">•</span>
|
||||
<span class="col" style="padding-right:0px" href="javascript:void(0)">Drag and drop</span>
|
||||
<span class="col" style="padding-right:0px">•</span>
|
||||
<a class="col" style="padding-right:0px" href="javascript:void(0)" (click)="openFileSelector()">Upload<span class="phone-hidden"> an image</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<ng-container *ngIf="mode === 'url'">
|
||||
<div class="row no-gutters mt-3 pb-3 ml-md-2 mr-md-2">
|
||||
<div class="input-group col-md-10 mr-md-2" style="width: 100%">
|
||||
<div class="input-group-prepend">
|
||||
<label class="input-group-text" for="load-image">Url</label>
|
||||
</div>
|
||||
<input type="text" autocomplete="off" class="form-control" formControlName="coverImageUrl" placeholder="https://" id="load-image" class="form-control">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" id="load-image-addon" (click)="loadImage(); mode='all';" [disabled]="form.get('coverImageUrl')?.value.length === 0">
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="col btn btn-secondary" href="javascript:void(0)" (click)="mode = 'all'" aria-label="Back">
|
||||
<i class="fas fa-share" aria-hidden="true" style="transform: rotateY(180deg)"></i>
|
||||
<span class="phone-hidden">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</ng-template>
|
||||
</ngx-file-drop>
|
||||
|
||||
<ng-template>
|
||||
|
||||
</ng-template>
|
||||
</form>
|
||||
|
||||
<div class="row no-gutters chooser" style="padding-top: 10px">
|
||||
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
|
||||
<img class="card-img-top" [src]="url" aria-hidden="true" height="230px" width="158px" (error)="imageService.updateErroredImage($event)">
|
||||
</div>
|
||||
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
|
||||
<img class="card-img-top" title="Reset Cover Image" [src]="imageService.resetCoverImage" aria-hidden="true" height="230px" width="158px">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
@import '../../../theme/colors';
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.card-img-top {
|
||||
width: $image-width;
|
||||
height: $image-height;
|
||||
max-height: $image-height;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
outline: 5px solid $primary-color;
|
||||
outline-width: medium;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
ngx-file-drop ::ng-deep > div {
|
||||
// styling for the outer drop box
|
||||
width: 100%;
|
||||
border: 2px solid $primary-color;
|
||||
border-radius: 5px;
|
||||
height: 100px;
|
||||
margin: auto;
|
||||
|
||||
> div {
|
||||
// styling for the inner box (template)
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
import { NgxFileDropEntry, FileSystemFileEntry } from 'ngx-file-drop';
|
||||
import { fromEvent, Subject } from 'rxjs';
|
||||
import { takeWhile } from 'rxjs/operators';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cover-image-chooser',
|
||||
templateUrl: './cover-image-chooser.component.html',
|
||||
styleUrls: ['./cover-image-chooser.component.scss']
|
||||
})
|
||||
export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() imageUrls: Array<string> = [];
|
||||
@Output() imageUrlsChange: EventEmitter<Array<string>> = new EventEmitter<Array<string>>();
|
||||
|
||||
/**
|
||||
* Should the control give the ability to select an image that emits the reset status for cover image
|
||||
*/
|
||||
@Input() showReset: boolean = false;
|
||||
@Output() resetClicked: EventEmitter<void> = new EventEmitter<void>();
|
||||
|
||||
/**
|
||||
* Emits the selected index. Used usually to check if something other than the default image was selected.
|
||||
*/
|
||||
@Output() imageSelected: EventEmitter<number> = new EventEmitter<number>();
|
||||
/**
|
||||
* Emits a base64 encoded image
|
||||
*/
|
||||
@Output() selectedBase64Url: EventEmitter<string> = new EventEmitter<string>();
|
||||
|
||||
|
||||
|
||||
selectedIndex: number = 0;
|
||||
form!: FormGroup;
|
||||
files: NgxFileDropEntry[] = [];
|
||||
|
||||
mode: 'file' | 'url' | 'all' = 'all';
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.form = this.fb.group({
|
||||
coverImageUrl: new FormControl('', [])
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
getBase64Image(img: HTMLImageElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext("2d", {alpha: false});
|
||||
if (!ctx) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
var dataURL = canvas.toDataURL("image/png");
|
||||
return dataURL;
|
||||
}
|
||||
|
||||
selectImage(index: number) {
|
||||
if (this.selectedIndex === index) { return; }
|
||||
this.selectedIndex = index;
|
||||
this.imageSelected.emit(this.selectedIndex);
|
||||
const selector = `.chooser img[src="${this.imageUrls[this.selectedIndex]}"]`;
|
||||
|
||||
|
||||
const elem = document.querySelector(selector) || document.querySelectorAll('.chooser img.card-img-top')[this.selectedIndex];
|
||||
if (elem) {
|
||||
const imageElem = <HTMLImageElement>elem;
|
||||
if (imageElem.src.startsWith('data')) {
|
||||
this.selectedBase64Url.emit(imageElem.src);
|
||||
return;
|
||||
}
|
||||
const image = this.getBase64Image(imageElem);
|
||||
if (image != '') {
|
||||
this.selectedBase64Url.emit(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
const url = this.form.get('coverImageUrl')?.value.trim();
|
||||
if (url && url != '') {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = this.form.get('coverImageUrl')?.value;
|
||||
img.onload = (e) => this.handleUrlImageAdd(e);
|
||||
img.onerror = (e) => {
|
||||
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
|
||||
this.form.get('coverImageUrl')?.setValue('');
|
||||
}
|
||||
this.form.get('coverImageUrl')?.setValue('');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.files = files;
|
||||
for (const droppedFile of files) {
|
||||
|
||||
// Is it a file?
|
||||
if (droppedFile.fileEntry.isFile) {
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
|
||||
fileEntry.file((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => this.handleFileImageAdd(e);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleFileImageAdd(e: any) {
|
||||
if (e.target == null) return;
|
||||
|
||||
this.imageUrls.push(e.target.result);
|
||||
this.imageUrlsChange.emit(this.imageUrls);
|
||||
this.selectedIndex += 1;
|
||||
this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image
|
||||
this.selectedBase64Url.emit(e.target.result);
|
||||
}
|
||||
|
||||
handleUrlImageAdd(e: any) {
|
||||
console.log(e);
|
||||
if (e.path === null || e.path.length === 0) return;
|
||||
|
||||
const url = this.getBase64Image(e.path[0]);
|
||||
this.imageUrls.push(url);
|
||||
this.imageUrlsChange.emit(this.imageUrls);
|
||||
|
||||
setTimeout(() => {
|
||||
// Auto select newly uploaded image and tell parent of new base64 url
|
||||
this.selectImage(this.selectedIndex + 1)
|
||||
});
|
||||
}
|
||||
|
||||
public fileOver(event: any){
|
||||
}
|
||||
|
||||
public fileLeave(event: any){
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.resetClicked.emit();
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
|
||||
setupEnterHandler() {
|
||||
setTimeout(() => {
|
||||
const elem = document.querySelector('input[id="load-image"]');
|
||||
if (elem == null) return;
|
||||
fromEvent(elem, 'keydown')
|
||||
.pipe(takeWhile(() => this.mode === 'url')).subscribe((event) => {
|
||||
const evt = <KeyboardEvent>event;
|
||||
switch(evt.key) {
|
||||
case KEY_CODES.ENTER:
|
||||
{
|
||||
this.loadImage();
|
||||
break;
|
||||
}
|
||||
|
||||
case KEY_CODES.ESC_KEY:
|
||||
this.mode = 'all';
|
||||
event.stopPropagation();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,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>
|
|
@ -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);
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.scrollable-modal {
|
||||
max-height: 600px;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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}]
|
||||
})
|
||||
|
|
BIN
UI/Web/src/assets/images/image-reset-cover-min.png
Normal file
BIN
UI/Web/src/assets/images/image-reset-cover-min.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 780 B |
BIN
UI/Web/src/assets/images/image-reset-cover.png
Normal file
BIN
UI/Web/src/assets/images/image-reset-cover.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
|
@ -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: (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue