diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java index c15e2b14..5f56b15f 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/DefaultUserSettingsProvider.java @@ -32,7 +32,7 @@ public class DefaultUserSettingsProvider { defaultSettings.put(UserSettingKey.ENTITY_VIEW_PREFERENCES, this::buildDefaultEntityViewPreferences); defaultSettings.put(UserSettingKey.TABLE_COLUMN_PREFERENCE, () -> null); defaultSettings.put(UserSettingKey.FILTER_MODE, () -> "and"); - defaultSettings.put(UserSettingKey.FILTER_SORTING_MODE, () -> "alphabetical"); + defaultSettings.put(UserSettingKey.FILTER_SORTING_MODE, () -> "count"); defaultSettings.put(UserSettingKey.METADATA_CENTER_VIEW_MODE, () -> "route"); } diff --git a/booklore-ui/src/app/core/services/loading.service.ts b/booklore-ui/src/app/core/services/loading.service.ts new file mode 100644 index 00000000..9b2f29da --- /dev/null +++ b/booklore-ui/src/app/core/services/loading.service.ts @@ -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 = ` +
+ +

${message}

+
+ `; + 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'; + } +} + diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts index 9b9dc07b..b340a379 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.ts @@ -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 { diff --git a/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts b/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts index 0eb1c925..efafb4d5 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/lock-unlock-metadata-dialog/lock-unlock-metadata-dialog.component.ts @@ -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 = {}; bookIds: Set = 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.' + }); + } + }); } } diff --git a/booklore-ui/src/app/features/book/components/shelf-assigner/shelf-assigner.component.ts b/booklore-ui/src/app/features/book/components/shelf-assigner/shelf-assigner.component.ts index a4dd7118..68e7d97d 100644 --- a/booklore-ui/src/app/features/book/components/shelf-assigner/shelf-assigner.component.ts +++ b/booklore-ui/src/app/features/book/components/shelf-assigner/shelf-assigner.component.ts @@ -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 = this.shelfService.shelfState$; book: Book = this.dynamicDialogConfig.data.book; @@ -62,16 +64,20 @@ export class ShelfAssignerComponent implements OnInit { } private updateBookShelves(bookIds: Set, idsToAssign: Set, idsToUnassign: Set): 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): Set { diff --git a/booklore-ui/src/app/features/book/service/book-menu.service.ts b/booklore-ui/src/app/features/book/service/book-menu.service.ts index 20464b69..23d85f89 100644 --- a/booklore-ui/src/app/features/book/service/book-menu.service.ts +++ b/booklore-ui/src/app/features/book/service/book-menu.service.ts @@ -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): 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 + }); + } + }); } }); } diff --git a/booklore-ui/src/app/features/book/service/library-shelf-menu.service.ts b/booklore-ui/src/app/features/book/service/library-shelf-menu.service.ts index 8d3ff71e..33e69671 100644 --- a/booklore-ui/src/app/features/book/service/library-shelf-menu.service.ts +++ b/booklore-ui/src/app/features/book/service/library-shelf-menu.service.ts @@ -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', + }); + } + }); } }); } diff --git a/booklore-ui/src/app/features/metadata/component/metadata-manager/metadata-manager.component.ts b/booklore-ui/src/app/features/metadata/component/metadata-manager/metadata-manager.component.ts index dc010d66..689371c3 100644 --- a/booklore-ui/src/app/features/metadata/component/metadata-manager/metadata-manager.component.ts +++ b/booklore-ui/src/app/features/metadata/component/metadata-manager/metadata-manager.component.ts @@ -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}`); }