Bugfixes and Cover Chooser Upgrades (#1146)

* Fixed a bug where GetNextChapter would return a loose leaf chapter from a special when it should return nothing.

* Fixed a bug in events widget when an update comes in after a user refreshes, the active event counter could get out of sync, thus showing "Nothing going on here"

Refactored the events widget to be named appropriately.

* Refactored code to have errors during threaded tasks propagate to the UI via events widget (css still needed).

Removed ScanLibraryError in favor of generic Error event.

* Fixed up some code and added ability to remove the event from events widget

* Fixed a bug where modifiying certain fields, like summary, wouldn't lock the field

* Fixed a few bugs where lock state was not being set in the DB correctly nor were certain combinations of locking fields and editing fields.

* Removed debug code

* Updated the discord alert to tag new group

* Refactored cover upload to actually handle uploading a temp file via url on the backend so that users can user change cover by url. Fixed up some bugs that occured when chaning the image container in a previous PR.

* Code cleanup

* Cleaned up the css on the error items

* Code cleanup
This commit is contained in:
Joseph Milazzo 2022-03-16 17:02:24 -05:00 committed by GitHub
parent d2f05cf5ae
commit e41b455d09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 363 additions and 158 deletions

View file

@ -0,0 +1,32 @@
import { EVENTS } from "src/app/_services/message-hub.service";
export interface ErrorEvent {
/**
* Payload of the event subtype
*/
body: any;
/**
* Subtype event
*/
name: EVENTS.Error;
/**
* Title to display in events widget
*/
title: string;
/**
* Optional subtitle to display. Defaults to empty string
*/
subTitle: string;
/**
* Type of event. Helps events widget to understand how to handle said event
*/
eventType: 'single';
/**
* Type of progress. Helps widget understand how to display spinner
*/
progress: 'none';
/**
* When event was sent
*/
eventTime: string;
}

View file

@ -83,6 +83,10 @@ export class ImageService implements OnDestroy {
return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey);
}
getCoverUploadImage(filename: string) {
return this.baseUrl + 'image/cover-upload?filename=' + encodeURIComponent(filename);
}
updateErroredImage(event: any) {
event.target.src = this.placeholderImage;
}

View file

@ -16,7 +16,10 @@ export enum EVENTS {
ScanLibraryProgress = 'ScanLibraryProgress',
OnlineUsers = 'OnlineUsers',
SeriesAddedToCollection = 'SeriesAddedToCollection',
ScanLibraryError = 'ScanLibraryError',
/**
* A generic error that occurs during operations on the server
*/
Error = 'Error',
BackupDatabaseProgress = 'BackupDatabaseProgress',
/**
* A subtype of NotificationProgress that represents maintenance cleanup on server-owned resources
@ -149,15 +152,11 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.ScanLibraryError, resp => {
this.hubConnection.on(EVENTS.Error, resp => {
this.messagesSource.next({
event: EVENTS.ScanLibraryError,
event: EVENTS.Error,
payload: resp.body
});
if (this.isAdmin) {
// TODO: Just show the error, RBS is done in eventhub
this.toastr.error('Library Scan had a critical error. Some series were not saved. Check logs');
}
});
this.hubConnection.on(EVENTS.SeriesAdded, resp => {

View file

@ -12,6 +12,10 @@ export class UploadService {
constructor(private httpClient: HttpClient) { }
uploadByUrl(url: string) {
return this.httpClient.post<string>(this.baseUrl + 'upload/upload-by-url', {url}, {responseType: 'text' as 'json'});
}
/**
*
* @param seriesId Series to overwrite cover image for

View file

@ -27,7 +27,7 @@ import { CardsModule } from './cards/cards.module';
import { CollectionsModule } from './collections/collections.module';
import { ReadingListModule } from './reading-list/reading-list.module';
import { SAVER, getSaver } from './shared/_providers/saver.provider';
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
import { EventsWidgetComponent } from './events-widget/events-widget.component';
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
import { AllSeriesComponent } from './all-series/all-series.component';
import { RegistrationModule } from './registration/registration.module';
@ -48,7 +48,7 @@ import { ColorPickerModule } from 'ngx-color-picker';
ReviewSeriesModalComponent,
RecentlyAddedComponent,
DashboardComponent,
NavEventsToggleComponent,
EventsWidgetComponent,
SeriesMetadataDetailComponent,
AllSeriesComponent,
GroupedTypeaheadComponent,

View file

@ -32,6 +32,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
@Input() series!: Series;
seriesVolumes: any[] = [];
isLoadingVolumes = false;
/**
* A copy of the series from init. This is used to compare values for name fields to see if lock was modified
*/
initSeries!: Series;
isCollapsed = true;
volumeCollapsed: any = {};
@ -94,6 +98,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.imageUrls.push(this.imageService.getSeriesCoverImage(this.series.id));
this.initSeries = Object.assign({}, this.series);
this.libraryService.getLibraryNames().pipe(takeUntil(this.onDestroy)).subscribe(names => {
this.libraryName = names[this.series.libraryId];
});
@ -133,28 +139,24 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.metadata = metadata;
this.setupTypeaheads();
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
this.editSeriesForm.get('ageRating')?.setValue(this.metadata.ageRating);
this.editSeriesForm.get('publicationStatus')?.setValue(this.metadata.publicationStatus);
this.editSeriesForm.get('language')?.setValue(this.metadata.language);
this.editSeriesForm.get('summary')?.patchValue(this.metadata.summary);
this.editSeriesForm.get('ageRating')?.patchValue(this.metadata.ageRating);
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
if (!this.editSeriesForm.get('name')?.touched) return;
this.series.nameLocked = true;
});
this.editSeriesForm.get('sortName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
if (!this.editSeriesForm.get('sortName')?.touched) return;
this.series.sortNameLocked = true;
});
this.editSeriesForm.get('localizedName')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
if (!this.editSeriesForm.get('localizedName')?.touched) return;
this.series.localizedNameLocked = true;
});
this.editSeriesForm.get('summary')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
if (!this.editSeriesForm.get('summary')?.touched) return;
this.metadata.summaryLocked = true;
this.metadata.summary = val;
});
@ -203,7 +205,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.setupLanguageTypeahead()
]).subscribe(results => {
this.collectionTags = this.metadata.collectionTags;
this.editSeriesForm.get('summary')?.setValue(this.metadata.summary);
});
}
@ -345,7 +346,6 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
]).pipe(map(results => {
//this.resetTypeaheads.next(true);
return of(true);
}));
}
@ -406,7 +406,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
];
// We only need to call updateSeries if we changed name, sort name, or localized name or reset a cover image
if (this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty || this.coverImageReset) {
const nameFieldsDirty = this.editSeriesForm.get('name')?.dirty || this.editSeriesForm.get('sortName')?.dirty || this.editSeriesForm.get('localizedName')?.dirty;
const nameFieldLockChanged = this.series.nameLocked !== this.initSeries.nameLocked || this.series.sortNameLocked !== this.initSeries.sortNameLocked || this.series.localizedNameLocked !== this.initSeries.localizedNameLocked;
if (nameFieldsDirty || nameFieldLockChanged || this.coverImageReset) {
model.nameLocked = this.series.nameLocked;
model.sortNameLocked = this.series.sortNameLocked;
model.localizedNameLocked = this.series.localizedNameLocked;
apis.push(this.seriesService.updateSeries(model));
}

View file

@ -6,6 +6,7 @@ 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';
import { UploadService } from 'src/app/_services/upload.service';
@Component({
selector: 'app-cover-image-chooser',
@ -41,7 +42,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
mode: 'file' | 'url' | 'all' = 'all';
private readonly onDestroy = new Subject<void>();
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService) { }
constructor(public imageService: ImageService, private fb: FormBuilder, private toastr: ToastrService, private uploadService: UploadService) { }
ngOnInit(): void {
this.form = this.fb.group({
@ -72,49 +73,31 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
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);
}
}
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
}
loadImage() {
const url = this.form.get('coverImageUrl')?.value.trim();
if (url && url != '') {
const img = new Image();
img.crossOrigin = 'Anonymous';
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('');
};
img.src = this.form.get('coverImageUrl')?.value;
this.form.get('coverImageUrl')?.setValue('');
this.uploadService.uploadByUrl(url).subscribe(filename => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = this.imageService.getCoverUploadImage(filename);
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('');
});
}
}
changeMode(mode: 'url') {
this.mode = mode;
this.setupEnterHandler();
setTimeout(() => {
})
}
public dropped(files: NgxFileDropEntry[]) {
this.files = files;
@ -151,7 +134,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
setTimeout(() => {
// Auto select newly uploaded image and tell parent of new base64 url
this.selectImage(this.selectedIndex + 1)
this.selectImage(this.selectedIndex + 1);
});
}

View file

@ -1,7 +1,7 @@
<ng-container *ngIf="isAdmin">
<button type="button" class="btn btn-icon {{activeEvents > 0 ? 'colored' : ''}}"
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
</button>
@ -10,7 +10,6 @@
<ng-container *ngIf="debugMode">
<li class="list-group-item dark-menu-item">
<!-- <div class="spinner-grow text-primary small-spinner" role="status"></div> -->
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
<div class="progress-container row g-0 align-items-center">
@ -34,8 +33,14 @@
</div>
</div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item error">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
</ng-container>
<!-- Progress Events-->
@ -78,12 +83,26 @@
</ng-container>
</ng-container>
<!-- Errors -->
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngFor="let error of errors">
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMoreError(error)">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>{{error.title}}</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" (click)="removeError(error, $event)"></button>
</li>
</ng-container>
</ng-container>
<!-- Online Users -->
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers">
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
<div>{{onlineUsers.length}} Users online</div>
</li>
<li class="list-group-item dark-menu-item" *ngIf="activeEvents < 1 && onlineUsers.length <= 1">Not much going on here</li>
<li class="list-group-item dark-menu-item" *ngIf="debugMode">Active Events: {{activeEvents}}</li>
</ng-container>
</ul>
</ng-template>

View file

@ -73,4 +73,23 @@
color: var(--primary-color) !important;
}
color: var(--primary-color);
}
.error {
cursor: pointer;
position: relative;
.h6 {
color: var(--error-color);
}
i.fa {
color: var(--primary-color) !important;
}
.btn-close {
top: 5px;
right: 10px;
font-size: 11px;
position: absolute;
}
}

View file

@ -1,4 +1,4 @@
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -8,17 +8,17 @@ import { UpdateVersionEvent } from '../_models/events/update-version-event';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
import { ErrorEvent } from '../_models/events/error-event';
import { ConfirmService } from '../shared/confirm.service';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ServerService } from '../_services/server.service';
// TODO: Rename this to events widget
@Component({
selector: 'app-nav-events-toggle',
templateUrl: './nav-events-toggle.component.html',
styleUrls: ['./nav-events-toggle.component.scss']
templateUrl: './events-widget.component.html',
styleUrls: ['./events-widget.component.scss']
})
export class NavEventsToggleComponent implements OnInit, OnDestroy {
export class EventsWidgetComponent implements OnInit, OnDestroy {
@Input() user!: User;
isAdmin: boolean = false;
@ -34,6 +34,9 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
singleUpdateSource = new BehaviorSubject<NotificationProgressEvent[]>([]);
singleUpdates$ = this.singleUpdateSource.asObservable();
errorSource = new BehaviorSubject<ErrorEvent[]>([]);
errors$ = this.errorSource.asObservable();
private updateNotificationModalRef: NgbModalRef | null = null;
activeEvents: number = 0;
@ -45,22 +48,27 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
return EVENTS;
}
constructor(public messageHub: MessageHubService, private modalService: NgbModal, private accountService: AccountService) { }
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
private accountService: AccountService, private confirmService: ConfirmService) { }
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
this.progressEventsSource.complete();
this.singleUpdateSource.complete();
this.errorSource.complete();
}
ngOnInit(): void {
// Debounce for testing. Kavita's too fast
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
if (event.event.endsWith('error')) {
// TODO: Show an error handle
} else if (event.event === EVENTS.NotificationProgress) {
if (event.event === EVENTS.NotificationProgress) {
this.processNotificationProgressEvent(event);
} else if (event.event === EVENTS.Error) {
const values = this.errorSource.getValue();
values.push(event.payload as ErrorEvent);
this.errorSource.next(values);
this.activeEvents += 1;
}
});
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
@ -94,6 +102,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
const index = data.findIndex(m => m.name === message.name);
if (index < 0) {
data.push(message);
this.activeEvents += 1;
} else {
data[index] = message;
}
@ -103,7 +112,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
data = this.progressEventsSource.getValue();
data = data.filter(m => m.name !== message.name); // This does not work // && m.title !== message.title
this.progressEventsSource.next(data);
this.activeEvents = Math.max(this.activeEvents - 1, 0);
this.activeEvents = Math.max(this.activeEvents - 1, 0);
break;
default:
break;
@ -123,6 +132,31 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy {
});
}
async seeMoreError(error: ErrorEvent) {
const config = new ConfirmConfig();
config.buttons = [
{text: 'Dismiss', type: 'primary'},
{text: 'Ok', type: 'secondary'},
];
config.header = error.title;
config.content = error.subTitle;
var result = await this.confirmService.alert(error.subTitle || error.title, config);
if (result) {
this.removeError(error);
}
}
removeError(error: ErrorEvent, event?: MouseEvent) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
let data = this.errorSource.getValue();
data = data.filter(m => m !== error);
this.errorSource.next(data);
this.activeEvents = Math.max(this.activeEvents - 1, 0);
}
prettyPrintProgress(progress: number) {
return Math.trunc(progress * 100);
}

View file

@ -532,6 +532,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
modalRef.closed.subscribe((closeResult: {success: boolean, series: Series, coverImageUpdate: boolean}) => {
window.scrollTo(0, 0);
if (closeResult.success) {
this.seriesService.getSeries(this.seriesId).subscribe(s => {
this.series = s;
});
this.loadSeries(this.seriesId);
if (closeResult.coverImageUpdate) {
// Random triggers a load change without any problems with API

View file

@ -1,4 +1,7 @@
export interface ConfirmButton {
text: string;
/**
* Type for css class. ie) primary, secondary
*/
type: string;
}