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 = `
+
+ `;
+ 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}`);
}