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,65 +0,0 @@
|
|||
<div *ngIf="data !== undefined">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">
|
||||
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}} Details</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
<h4 *ngIf="isObjectVolume(data)">Information</h4>
|
||||
|
||||
<ng-container *ngIf="isObjectVolume(data) || isObjectChapter(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)">
|
||||
Added: {{(data.created | date: 'MM/dd/yyyy') || '-'}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Pages: {{data.pages}}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<h4 *ngIf="!isObjectChapter(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)}}">
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0 mb-1">
|
||||
<span *ngIf="chapter.number !== '0'; else specialHeader">
|
||||
Chapter {{formatChapterNumber(chapter)}}
|
||||
</span>
|
||||
<ng-template #specialHeader>File(s)</ng-template>
|
||||
</h5>
|
||||
<ul class="list-group">
|
||||
<li *ngFor="let file of chapter.files" class="list-group-item"> <!-- .sort() -->
|
||||
<span>{{file.filePath}}</span>
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
Pages: {{file.pages}}
|
||||
</div>
|
||||
<div class="col">
|
||||
Format: <span class="badge badge-secondary">{{utilityService.mangaFormatToText(file.format)}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="submit" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -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,89 +0,0 @@
|
|||
<div class="container-fluid" style="padding-top: 10px">
|
||||
<div class="row no-gutters pb-2">
|
||||
<div class="col mr-auto">
|
||||
<h2 style="display: inline-block">
|
||||
<span *ngIf="actions.length > 0" class="">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
</span> {{header}}
|
||||
<!-- NOTE: On mobile the pill can eat up a lot of space, we can hide it and move to the filter section if user is interested -->
|
||||
<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button *ngIf="filters !== undefined && filters.length > 0" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="sr-only">Sort / Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="row no-gutters filter-section" #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
|
||||
<div class="col">
|
||||
<form class="ml-2" [formGroup]="filterForm">
|
||||
<div class="form-group" *ngIf="filters.length > 0">
|
||||
<label for="series-filter">Filter</label>
|
||||
<select class="form-control" id="series-filter" formControlName="filter" (ngModelChange)="handleFilterChange($event)" style="max-width: 200px;">
|
||||
<option [value]="i" *ngFor="let opt of filters; let i = index">{{opt.title}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
|
||||
|
||||
|
||||
<div class="row no-gutters">
|
||||
<div class="col-auto" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
|
||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||
</div>
|
||||
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
There is no data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template #paginationTemplate let-id="id">
|
||||
<div class="d-flex justify-content-center" *ngIf="pagination && items.length > 0">
|
||||
<ngb-pagination
|
||||
*ngIf="pagination.totalPages > 1"
|
||||
[maxSize]="8"
|
||||
[rotate]="true"
|
||||
[ellipses]="false"
|
||||
[(page)]="pagination.currentPage"
|
||||
[pageSize]="pagination.itemsPerPage"
|
||||
(pageChange)="onPageChange($event)"
|
||||
[collectionSize]="pagination.totalItems">
|
||||
|
||||
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
|
||||
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
|
||||
<div class="form-group d-flex flex-nowrap px-2">
|
||||
<label
|
||||
id="paginationInputLabel-{{id}}"
|
||||
for="paginationInput-{{id}}"
|
||||
class="col-form-label mr-2 ml-1"
|
||||
>Page</label>
|
||||
<input #i
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
class="form-control custom-pages-input"
|
||||
id="paginationInput-{{id}}"
|
||||
[value]="page"
|
||||
(keyup.enter)="selectPageStr(i.value)"
|
||||
(blur)="selectPageStr(i.value)"
|
||||
(input)="formatInput($any($event).target)"
|
||||
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
|
||||
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
|
||||
/>
|
||||
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
|
||||
of {{pagination.totalPages}}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ng-template>
|
||||
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
|
||||
import { FormGroup, FormControl } from '@angular/forms';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { FilterItem } from 'src/app/_models/series-filter';
|
||||
import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
||||
const FILTER_PAG_REGEX = /[^0-9]/g;
|
||||
|
||||
export enum FilterAction {
|
||||
/**
|
||||
* If an option is selected on a multi select component
|
||||
*/
|
||||
Added = 0,
|
||||
/**
|
||||
* If an option is unselected on a multi select component
|
||||
*/
|
||||
Removed = 1,
|
||||
/**
|
||||
* If an option is selected on a single select component
|
||||
*/
|
||||
Selected = 2
|
||||
}
|
||||
|
||||
export interface UpdateFilterEvent {
|
||||
filterItem: FilterItem;
|
||||
action: FilterAction;
|
||||
}
|
||||
|
||||
const ANIMATION_SPEED = 300;
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-detail-layout',
|
||||
templateUrl: './card-detail-layout.component.html',
|
||||
styleUrls: ['./card-detail-layout.component.scss']
|
||||
})
|
||||
export class CardDetailLayoutComponent implements OnInit {
|
||||
|
||||
@Input() header: string = '';
|
||||
@Input() isLoading: boolean = false;
|
||||
@Input() items: any[] = [];
|
||||
@Input() pagination!: Pagination;
|
||||
/**
|
||||
* Any actions to exist on the header for the parent collection (library, collection)
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
/**
|
||||
* A list of Filters which can filter the data of the page. If nothing is passed, the control will not show.
|
||||
*/
|
||||
@Input() filters: Array<FilterItem> = [];
|
||||
@Input() trackByIdentity!: (index: number, item: any) => string;
|
||||
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
|
||||
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
|
||||
@Output() applyFilter: EventEmitter<UpdateFilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
filterForm: FormGroup = new FormGroup({
|
||||
filter: new FormControl(0, []),
|
||||
});
|
||||
|
||||
/**
|
||||
* Controls the visiblity of extended controls that sit below the main header.
|
||||
*/
|
||||
filteringCollapsed: boolean = true;
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.filterForm.get('filter')?.value}_${item.id}_${index}`;
|
||||
}
|
||||
|
||||
onPageChange(page: number) {
|
||||
this.pageChange.emit(this.pagination);
|
||||
}
|
||||
|
||||
selectPageStr(page: string) {
|
||||
this.pagination.currentPage = parseInt(page, 10) || 1;
|
||||
this.onPageChange(this.pagination.currentPage);
|
||||
}
|
||||
|
||||
formatInput(input: HTMLInputElement) {
|
||||
input.value = input.value.replace(FILTER_PAG_REGEX, '');
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
handleFilterChange(index: string) {
|
||||
this.applyFilter.emit({
|
||||
filterItem: this.filters[parseInt(index, 10)],
|
||||
action: FilterAction.Selected
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
<ng-container *ngIf="actions.length > 0">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
|
||||
<button ngbDropdownItem *ngFor="let action of nonAdminActions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
<div class="dropdown-divider" *ngIf="nonAdminActions.length > 1 && adminActions.length > 1"></div>
|
||||
<button ngbDropdownItem *ngFor="let action of adminActions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
|
@ -1,3 +0,0 @@
|
|||
.dropdown-toggle:after {
|
||||
content: none !important;
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-actionables',
|
||||
templateUrl: './card-actionables.component.html',
|
||||
styleUrls: ['./card-actionables.component.scss']
|
||||
})
|
||||
export class CardActionablesComponent implements OnInit {
|
||||
|
||||
@Input() iconClass = 'fa-ellipsis-v';
|
||||
@Input() btnClass = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() labelBy = 'card';
|
||||
@Input() disabled: boolean = false;
|
||||
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
|
||||
|
||||
adminActions: ActionItem<any>[] = [];
|
||||
nonAdminActions: ActionItem<any>[] = [];
|
||||
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.nonAdminActions = this.actions.filter(item => !item.requiresAdmin);
|
||||
this.adminActions = this.actions.filter(item => item.requiresAdmin);
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(event: any, action: ActionItem<any>) {
|
||||
this.preventClick(event);
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
this.actionHandler.emit(action);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Insert hr to separate admin actions
|
||||
|
||||
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
<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"
|
||||
aria-hidden="true" height="230px" width="158px">
|
||||
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
|
||||
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
|
||||
|
||||
<span class="download" *ngIf="download$ | async as download">
|
||||
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
|
||||
<span class="sr-only" role="status">
|
||||
{{download.progress}}% downloaded
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="error-banner" *ngIf="total === 0 && !supressArchiveWarning">
|
||||
Cannot Read Archive
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
|
||||
</div>
|
||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||
<div>
|
||||
<span class="card-title" placement="top" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
|
||||
<span *ngIf="isPromoted()">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
</span>
|
||||
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="sr-only">{{utilityService.mangaFormat(format)}}</span>
|
||||
{{title}}
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
<span class="card-actions float-right">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
</span>
|
||||
</div>
|
||||
<a class="card-title library" [routerLink]="['/library', libraryId]" routerLinkActive="router-link-active" *ngIf="!supressLibraryLink && libraryName">{{libraryName | titlecase}}</a>
|
||||
</div>
|
||||
</div>
|
|
@ -1,107 +0,0 @@
|
|||
@import '../../../theme/colors';
|
||||
|
||||
$triangle-size: 40px;
|
||||
$image-height: 230px;
|
||||
$image-width: 160px;
|
||||
|
||||
.error-banner {
|
||||
width: 160px;
|
||||
height: 18px;
|
||||
background-color: $error-color;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 5px;
|
||||
max-width: $image-width;
|
||||
cursor: pointer;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 13px;
|
||||
width: 130px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.card-img-top {
|
||||
height: $image-height;
|
||||
}
|
||||
|
||||
.progress-banner {
|
||||
width: 160px;
|
||||
height: 5px;
|
||||
|
||||
.progress {
|
||||
color: $primary-color;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
right: 30%;
|
||||
}
|
||||
|
||||
.not-read-badge {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 $triangle-size $triangle-size 0;
|
||||
border-color: transparent $primary-color transparent transparent;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
height: $image-height;
|
||||
|
||||
&:hover {
|
||||
visibility: visible;
|
||||
|
||||
.overlay-item {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-item {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
position: absolute;
|
||||
top: 228px;
|
||||
right: 5px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 5px !important;
|
||||
}
|
||||
|
||||
.library {
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
margin-top: 0px;
|
||||
}
|
|
@ -1,156 +0,0 @@
|
|||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler, Observable, Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { Download } from '../_models/download';
|
||||
import { DownloadService } from '../_services/download.service';
|
||||
import { UtilityService } from '../_services/utility.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
templateUrl: './card-item.component.html',
|
||||
styleUrls: ['./card-item.component.scss']
|
||||
})
|
||||
export class CardItemComponent implements OnInit, OnDestroy {
|
||||
|
||||
@Input() imageUrl = '';
|
||||
@Input() title = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() read = 0; // Pages read
|
||||
@Input() total = 0; // Total Pages
|
||||
@Input() supressLibraryLink = false;
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag; // This is the entity we are representing. It will be returned if an action is executed.
|
||||
@Output() clicked = new EventEmitter<string>();
|
||||
|
||||
libraryName: string | undefined = undefined; // Library name item belongs to
|
||||
libraryId: number | undefined = undefined;
|
||||
supressArchiveWarning: boolean = false; // This will supress the cannot read archive warning when total pages is 0
|
||||
format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public imageSerivce: ImageService, private libraryService: LibraryService,
|
||||
public utilityService: UtilityService, private downloadService: DownloadService,
|
||||
private toastr: ToastrService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
|
||||
this.supressArchiveWarning = true;
|
||||
}
|
||||
|
||||
if (this.supressLibraryLink === false) {
|
||||
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
|
||||
if (this.entity !== undefined && this.entity.hasOwnProperty('libraryId')) {
|
||||
this.libraryId = (this.entity as Series).libraryId;
|
||||
this.libraryName = names[this.libraryId];
|
||||
}
|
||||
});
|
||||
}
|
||||
this.format = (this.entity as Series).format;
|
||||
|
||||
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
isNullOrEmpty(val: string) {
|
||||
return val === null || val === undefined || val === '';
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
if (this.downloadInProgress === true) {
|
||||
this.toastr.info('Download is already in progress. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.utilityService.isVolume(this.entity)) {
|
||||
const volume = this.utilityService.asVolume(this.entity);
|
||||
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadVolume(volume).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
} else if (this.utilityService.isChapter(this.entity)) {
|
||||
const chapter = this.utilityService.asChapter(this.entity);
|
||||
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
} else if (this.utilityService.isSeries(this.entity)) {
|
||||
const series = this.utilityService.asSeries(this.entity);
|
||||
this.downloadService.downloadSeriesSize(series.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadSeries(series).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.download$ = null;
|
||||
this.downloadInProgress = false;
|
||||
}));
|
||||
});
|
||||
}
|
||||
return; // Don't propagate the download from a card
|
||||
}
|
||||
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, this.entity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
isPromoted() {
|
||||
const tag = this.entity as CollectionTag;
|
||||
return tag.hasOwnProperty('promoted') && tag.promoted;
|
||||
}
|
||||
}
|
|
@ -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}]
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue