mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
Add loader for long running operations (#1790)
This commit is contained in:
75
booklore-ui/src/app/core/services/loading.service.ts
Normal file
75
booklore-ui/src/app/core/services/loading.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user