Add loader for long running operations (#1790)

This commit is contained in:
Aditya Chandel
2025-12-07 20:23:47 -07:00
committed by GitHub
parent b00bc5f908
commit 42d2e83599
8 changed files with 239 additions and 116 deletions

View File

@@ -0,0 +1,75 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class LoadingService {
private activeLoaders: HTMLElement[] = [];
show(message: string = 'Loading...'): HTMLElement {
const loader = document.createElement('div');
loader.className = 'fullscreen-loader';
loader.innerHTML = `
<div class="loader-content">
<i class="pi pi-spin pi-spinner" style="font-size: 3rem; color: var(--primary-color);"></i>
<p style="margin-top: 1rem; color: var(--text-color);">${message}</p>
</div>
`;
loader.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
backdrop-filter: blur(4px);
`;
const content = loader.querySelector('.loader-content') as HTMLElement;
if (content) {
content.style.cssText = `
text-align: center;
background: var(--surface-card);
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
`;
}
document.body.appendChild(loader);
document.body.style.cursor = 'wait';
this.activeLoaders.push(loader);
return loader;
}
hide(loader: HTMLElement): void {
if (loader && loader.parentNode) {
loader.parentNode.removeChild(loader);
const index = this.activeLoaders.indexOf(loader);
if (index > -1) {
this.activeLoaders.splice(index, 1);
}
if (this.activeLoaders.length === 0) {
document.body.style.cursor = 'default';
}
}
}
hideAll(): void {
this.activeLoaders.forEach(loader => {
if (loader && loader.parentNode) {
loader.parentNode.removeChild(loader);
}
});
this.activeLoaders = [];
document.body.style.cursor = 'default';
}
}

View File

@@ -5,7 +5,7 @@ import {PageTitleService} from "../../../../shared/service/page-title.service";
import {LibraryService} from '../../service/library.service';
import {BookService} from '../../service/book.service';
import {catchError, debounceTime, filter, map, switchMap, take} from 'rxjs/operators';
import {BehaviorSubject, combineLatest, Observable, of, Subject} from 'rxjs';
import {BehaviorSubject, combineLatest, finalize, Observable, of, Subject} from 'rxjs';
import {ShelfService} from '../../service/shelf.service';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {Library} from '../../model/library.model';
@@ -49,6 +49,7 @@ import {MetadataRefreshType} from '../../../metadata/model/request/metadata-refr
import {GroupRule} from '../../../magic-shelf/component/magic-shelf-component';
import {TaskHelperService} from '../../../settings/task-management/task-helper.service';
import {FilterLabelHelper} from './filter-label.helper';
import {LoadingService} from '../../../../core/services/loading.service';
export enum EntityType {
LIBRARY = 'Library',
@@ -118,6 +119,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
protected magicShelfService = inject(MagicShelfService);
protected bookRuleEvaluatorService = inject(BookRuleEvaluatorService);
private pageTitle = inject(PageTitleService);
private loadingService = inject(LoadingService);
protected taskHelperService = inject(TaskHelperService);
@@ -530,9 +532,14 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
header: 'Confirm Deletion',
icon: 'pi pi-exclamation-triangle',
accept: () => {
this.bookService.deleteBooks(this.selectedBooks).subscribe(() => {
this.selectedBooks.clear();
});
const count = this.selectedBooks.size;
const loader = this.loadingService.show(`Deleting ${count} book(s)...`);
this.bookService.deleteBooks(this.selectedBooks)
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe(() => {
this.selectedBooks.clear();
});
},
reject: () => {
}
@@ -615,15 +622,20 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
unshelfBooks() {
if (!this.entity) return;
this.bookService.updateBookShelves(this.selectedBooks, new Set(), new Set([this.entity.id])).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Books shelves updated'});
this.selectedBooks.clear();
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update books shelves'});
}
});
const count = this.selectedBooks.size;
const loader = this.loadingService.show(`Unshelving ${count} book(s)...`);
this.bookService.updateBookShelves(this.selectedBooks, new Set(), new Set([this.entity.id]))
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Books shelves updated'});
this.selectedBooks.clear();
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update books shelves'});
}
});
}
openShelfAssigner(): void {

View File

@@ -6,6 +6,8 @@ import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {MessageService} from 'primeng/api';
import {BookService} from '../../../service/book.service';
import {Divider} from 'primeng/divider';
import {LoadingService} from '../../../../../core/services/loading.service';
import {finalize} from 'rxjs';
@Component({
selector: 'app-lock-unlock-metadata-dialog',
@@ -23,6 +25,7 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
private dynamicDialogConfig = inject(DynamicDialogConfig);
private dialogRef = inject(DynamicDialogRef);
private messageService = inject(MessageService);
private loadingService = inject(LoadingService);
fieldLocks: Record<string, boolean | undefined> = {};
bookIds: Set<number> = this.dynamicDialogConfig.data.bookIds;
@@ -117,24 +120,29 @@ export class LockUnlockMetadataDialogComponent implements OnInit {
}
this.isSaving = true;
this.bookService.toggleFieldLocks(this.bookIds, fieldActions).subscribe({
next: () => {
const loader = this.loadingService.show('Updating field locks...');
this.bookService.toggleFieldLocks(this.bookIds, fieldActions)
.pipe(finalize(() => {
this.isSaving = false;
this.messageService.add({
severity: 'success',
summary: 'Field Locks Updated',
detail: 'Selected metadata fields have been updated successfully.'
});
this.dialogRef.close('fields-updated');
},
error: () => {
this.isSaving = false;
this.messageService.add({
severity: 'error',
summary: 'Failed to Update Field Locks',
detail: 'An error occurred while updating field lock statuses.'
});
}
});
this.loadingService.hide(loader);
}))
.subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Field Locks Updated',
detail: 'Selected metadata fields have been updated successfully.'
});
this.dialogRef.close('fields-updated');
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed to Update Field Locks',
detail: 'An error occurred while updating field lock statuses.'
});
}
});
}
}

View File

@@ -3,7 +3,7 @@ import {DynamicDialogConfig, DynamicDialogRef} from 'primeng/dynamicdialog';
import {Book} from '../../model/book.model';
import {MessageService} from 'primeng/api';
import {ShelfService} from '../../service/shelf.service';
import {Observable} from 'rxjs';
import {finalize, Observable} from 'rxjs';
import {BookService} from '../../service/book.service';
import {map, tap} from 'rxjs/operators';
import {Shelf} from '../../model/shelf.model';
@@ -13,6 +13,7 @@ import {AsyncPipe} from '@angular/common';
import {Checkbox} from 'primeng/checkbox';
import {FormsModule} from '@angular/forms';
import {BookDialogHelperService} from '../book-browser/BookDialogHelperService';
import {LoadingService} from '../../../../core/services/loading.service';
@Component({
selector: 'app-shelf-assigner',
@@ -34,6 +35,7 @@ export class ShelfAssignerComponent implements OnInit {
private messageService = inject(MessageService);
private bookService = inject(BookService);
private bookDialogHelper = inject(BookDialogHelperService);
private loadingService = inject(LoadingService);
shelfState$: Observable<ShelfState> = this.shelfService.shelfState$;
book: Book = this.dynamicDialogConfig.data.book;
@@ -62,16 +64,20 @@ export class ShelfAssignerComponent implements OnInit {
}
private updateBookShelves(bookIds: Set<number>, idsToAssign: Set<number | undefined>, idsToUnassign: Set<number>): void {
this.bookService.updateBookShelves(bookIds, idsToAssign, idsToUnassign).subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book shelves updated'});
this.dynamicDialogRef.close();
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update book shelves'});
this.dynamicDialogRef.close();
}
});
const loader = this.loadingService.show(`Updating shelves for ${bookIds.size} book(s)...`);
this.bookService.updateBookShelves(bookIds, idsToAssign, idsToUnassign)
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe({
next: () => {
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Book shelves updated'});
this.dynamicDialogRef.close();
},
error: () => {
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to update book shelves'});
this.dynamicDialogRef.close();
}
});
}
private getIdsToUnAssign(book: Book, idsToAssign: Set<number | undefined>): Set<number> {

View File

@@ -5,6 +5,8 @@ import {BookService} from './book.service';
import {readStatusLabels} from '../components/book-browser/book-filter/book-filter.component';
import {ReadStatus} from '../model/book.model';
import {ResetProgressTypes} from '../../../shared/constants/reset-progress-type';
import {finalize} from 'rxjs';
import {LoadingService} from '../../../core/services/loading.service';
@Injectable({
providedIn: 'root'
@@ -14,7 +16,7 @@ export class BookMenuService {
confirmationService = inject(ConfirmationService);
messageService = inject(MessageService);
bookService = inject(BookService);
loadingService = inject(LoadingService);
getMetadataMenuItems(
autoFetchMetadata: () => void,
@@ -46,6 +48,8 @@ export class BookMenuService {
}
getTieredMenuItems(selectedBooks: Set<number>): MenuItem[] {
const count = selectedBooks.size;
return [
{
label: 'Update Read Status',
@@ -54,30 +58,34 @@ export class BookMenuService {
label,
command: () => {
this.confirmationService.confirm({
message: `Are you sure you want to mark selected books as "${label}"?`,
message: `Are you sure you want to mark ${count} book(s) as "${label}"?`,
header: 'Confirm Read Status Update',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.bookService.updateBookReadStatus(Array.from(selectedBooks), status as ReadStatus).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Read Status Updated',
detail: `Marked as "${label}"`,
life: 2000
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Update Failed',
detail: 'Could not update read status.',
life: 3000
});
}
});
const loader = this.loadingService.show(`Updating read status for ${count} book(s)...`);
this.bookService.updateBookReadStatus(Array.from(selectedBooks), status as ReadStatus)
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Read Status Updated',
detail: `Marked as "${label}"`,
life: 2000
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Update Failed',
detail: 'Could not update read status.',
life: 3000
});
}
});
}
});
}
@@ -88,30 +96,34 @@ export class BookMenuService {
icon: 'pi pi-undo',
command: () => {
this.confirmationService.confirm({
message: 'Are you sure you want to reset Booklore reading progress for selected books?',
message: `Are you sure you want to reset Booklore reading progress for ${count} book(s)?`,
header: 'Confirm Reset',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.BOOKLORE).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Progress Reset',
detail: 'Booklore reading progress has been reset.',
life: 1500
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not reset progress.',
life: 1500
});
}
});
const loader = this.loadingService.show(`Resetting Booklore progress for ${count} book(s)...`);
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.BOOKLORE)
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Progress Reset',
detail: 'Booklore reading progress has been reset.',
life: 1500
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not reset progress.',
life: 1500
});
}
});
}
});
}
@@ -121,30 +133,34 @@ export class BookMenuService {
icon: 'pi pi-undo',
command: () => {
this.confirmationService.confirm({
message: 'Are you sure you want to reset KOReader reading progress for selected books?',
message: `Are you sure you want to reset KOReader reading progress for ${count} book(s)?`,
header: 'Confirm Reset',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Yes',
rejectLabel: 'No',
accept: () => {
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.KOREADER).subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Progress Reset',
detail: 'KOReader reading progress has been reset.',
life: 1500
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not reset progress.',
life: 1500
});
}
});
const loader = this.loadingService.show(`Resetting KOReader progress for ${count} book(s)...`);
this.bookService.resetProgress(Array.from(selectedBooks), ResetProgressTypes.KOREADER)
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe({
next: () => {
this.messageService.add({
severity: 'success',
summary: 'Progress Reset',
detail: 'KOReader reading progress has been reset.',
life: 1500
});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Could not reset progress.',
life: 1500
});
}
});
}
});
}

View File

@@ -16,6 +16,8 @@ import {TaskCreateRequest, TaskType} from '../../settings/task-management/task.s
import {MetadataRefreshRequest} from '../../metadata/model/request/metadata-refresh-request.model';
import {TaskHelperService} from '../../settings/task-management/task-helper.service';
import {UserService} from "../../settings/user-management/user.service";
import {LoadingService} from '../../../core/services/loading.service';
import {finalize} from 'rxjs';
@Injectable({
providedIn: 'root',
@@ -31,6 +33,7 @@ export class LibraryShelfMenuService {
private dialogService = inject(DialogService);
private magicShelfService = inject(MagicShelfService);
private userService = inject(UserService);
private loadingService = inject(LoadingService);
initializeLibraryMenuItems(entity: Library | Shelf | MagicShelf | null): MenuItem[] {
return [
@@ -130,19 +133,23 @@ export class LibraryShelfMenuService {
severity: 'danger',
},
accept: () => {
this.libraryService.deleteLibrary(entity?.id!).subscribe({
complete: () => {
this.router.navigate(['/']);
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Library was deleted'});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Failed to delete library',
});
}
});
const loader = this.loadingService.show(`Deleting library '${entity?.name}'...`);
this.libraryService.deleteLibrary(entity?.id!)
.pipe(finalize(() => this.loadingService.hide(loader)))
.subscribe({
complete: () => {
this.router.navigate(['/']);
this.messageService.add({severity: 'info', summary: 'Success', detail: 'Library was deleted'});
},
error: () => {
this.messageService.add({
severity: 'error',
summary: 'Failed',
detail: 'Failed to delete library',
});
}
});
}
});
}

View File

@@ -65,7 +65,6 @@ interface TabConfig {
export class MetadataManagerComponent implements OnInit, OnDestroy {
private bookService = inject(BookService);
private messageService = inject(MessageService);
private confirmationService = inject(ConfirmationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private pageTitle = inject(PageTitleService);
@@ -149,7 +148,7 @@ export class MetadataManagerComponent implements OnInit, OnDestroy {
}
updatePageTitle() {
const currentTab = this.tabConfigs.find((tab)=> tab.type === this._activeTab);
const currentTab = this.tabConfigs.find((tab) => tab.type === this._activeTab);
this.pageTitle.setPageTitle(`Metadata Manager: ${currentTab?.label ?? this._activeTab}`);
}