diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html index 0db905c40..fea36348c 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.html @@ -44,7 +44,8 @@ + tooltipPosition="top" + style="color: orange;"> } @@ -83,7 +84,6 @@ } -
+
@@ -217,15 +226,13 @@ }
- +
+ + +
@@ -396,19 +403,23 @@ } + @if (this.showFilter) {
+ + } - - + diff --git a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts index fa0c7e1b7..7a712172f 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-browser.component.ts @@ -1,4 +1,4 @@ -import {AfterViewInit, ChangeDetectorRef, Component, inject, OnInit, ViewChild} from '@angular/core'; +import {Component, inject, OnInit, ViewChild} from '@angular/core'; import {ActivatedRoute, Router} from '@angular/router'; import {ConfirmationService, MenuItem, MessageService, PrimeTemplate} from 'primeng/api'; import {LibraryService} from '../../service/library.service'; @@ -46,6 +46,7 @@ import {BookMenuService} from '../../service/book-menu.service'; import {MagicShelf, MagicShelfService} from '../../../magic-shelf.service'; import {BookRuleEvaluatorService} from '../../../book-rule-evaluator.service'; import {GroupRule} from '../../../magic-shelf-component/magic-shelf-component'; +import {SidebarFilterTogglePrefService} from './filters/sidebar-filter-toggle-pref-service'; export enum EntityType { LIBRARY = 'Library', @@ -87,18 +88,19 @@ const SORT_DIRECTION = { providers: [SeriesCollapseFilter], animations: [ trigger('slideInOut', [ - state('void', style({ transform: 'translateY(100%)' })), - state('*', style({ transform: 'translateY(0)' })), - transition(':enter', [ animate('0.1s ease-in') ]), - transition(':leave', [ animate('0.1s ease-out') ]) + state('void', style({transform: 'translateY(100%)'})), + state('*', style({transform: 'translateY(0)'})), + transition(':enter', [animate('0.1s ease-in')]), + transition(':leave', [animate('0.1s ease-out')]) ]) ] }) -export class BookBrowserComponent implements OnInit, AfterViewInit { +export class BookBrowserComponent implements OnInit { protected userService = inject(UserService); protected coverScalePreferenceService = inject(CoverScalePreferenceService); protected filterSortPreferenceService = inject(FilterSortPreferenceService); protected columnPreferenceService = inject(TableColumnPreferenceService); + protected sidebarFilterTogglePrefService = inject(SidebarFilterTogglePrefService); private activatedRoute = inject(ActivatedRoute); private messageService = inject(MessageService); private libraryService = inject(LibraryService); @@ -108,7 +110,6 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { private bookMenuService = inject(BookMenuService); private sortService = inject(SortService); private router = inject(Router); - private changeDetectorRef = inject(ChangeDetectorRef); private libraryShelfMenuService = inject(LibraryShelfMenuService); protected seriesCollapseFilter = inject(SeriesCollapseFilter); protected confirmationService = inject(ConfirmationService); @@ -148,14 +149,30 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { private headerFilter = new HeaderFilter(this.searchTerm$); protected bookSorter = new BookSorter(selectedSort => this.applySortOption(selectedSort)); - @ViewChild(BookTableComponent) bookTableComponent!: BookTableComponent; - @ViewChild(BookFilterComponent) bookFilterComponent!: BookFilterComponent; + @ViewChild(BookTableComponent) + bookTableComponent!: BookTableComponent; + @ViewChild(BookFilterComponent, { static: false }) + bookFilterComponent!: BookFilterComponent; - get currentCardSize() { return this.coverScalePreferenceService.currentCardSize; } - get gridColumnMinWidth(): string { return this.coverScalePreferenceService.gridColumnMinWidth; } - get viewIcon(): string { return this.currentViewMode === VIEW_MODES.GRID ? 'pi pi-objects-column' : 'pi pi-table'; } - get hasSidebarFilters(): boolean { return !!this.selectedFilter.value && Object.keys(this.selectedFilter.value).length > 0; } - get isFilterActive(): boolean { return this.selectedFilter.value !== null; } + get currentCardSize() { + return this.coverScalePreferenceService.currentCardSize; + } + + get gridColumnMinWidth(): string { + return this.coverScalePreferenceService.gridColumnMinWidth; + } + + get viewIcon(): string { + return this.currentViewMode === VIEW_MODES.GRID ? 'pi pi-objects-column' : 'pi pi-table'; + } + + get hasSidebarFilters(): boolean { + return !!this.selectedFilter.value && Object.keys(this.selectedFilter.value).length > 0; + } + + get isFilterActive(): boolean { + return this.selectedFilter.value !== null; + } ngOnInit(): void { this.coverScalePreferenceService.scaleChange$.pipe(debounceTime(1000)).subscribe(); @@ -197,23 +214,22 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { () => this.multiBookEditMetadata() ); this.tieredMenuItems = this.bookMenuService.getTieredMenuItems(this.selectedBooks); - } - ngAfterViewInit(): void { - combineLatest({ - paramMap: this.activatedRoute.queryParamMap, - user: this.userService.userState$.pipe( - filter(userState => !!userState?.user && userState.loaded), - take(1) - ) - }).subscribe(({paramMap, user}) => { + // --- NEW: Subscribe to query params + user changes for reactive updates --- + combineLatest([ + this.activatedRoute.paramMap, + this.activatedRoute.queryParamMap, + this.userService.userState$.pipe(filter(u => !!u?.user && u.loaded)) + ]).subscribe(([paramMap, queryParamMap, user]) => { - const viewParam = paramMap.get(QUERY_PARAMS.VIEW); - const sortParam = paramMap.get(QUERY_PARAMS.SORT); - const directionParam = paramMap.get(QUERY_PARAMS.DIRECTION); - const filterParams = paramMap.get(QUERY_PARAMS.FILTER); - const sidebarParam = paramMap.get(QUERY_PARAMS.SIDEBAR); - this.showFilter = sidebarParam === null ? true : sidebarParam === 'true'; + const viewParam = queryParamMap.get(QUERY_PARAMS.VIEW); + const sortParam = queryParamMap.get(QUERY_PARAMS.SORT); + const directionParam = queryParamMap.get(QUERY_PARAMS.DIRECTION); + const filterParams = queryParamMap.get(QUERY_PARAMS.FILTER); + + this.sidebarFilterTogglePrefService.showFilter$.subscribe(value => { + this.showFilter = value; + }); const parsedFilters: Record = {}; @@ -229,8 +245,10 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { }); this.selectedFilter.next(parsedFilters); - this.bookFilterComponent.setFilters?.(parsedFilters); - this.bookFilterComponent.onFiltersChanged?.(); + if(this.bookFilterComponent) { + this.bookFilterComponent.setFilters?.(parsedFilters); + this.bookFilterComponent.onFiltersChanged?.(); + } const firstFilter = filterParams.split(',')[0]; const [key, ...values] = firstFilter.split(':'); @@ -256,7 +274,6 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { this.columnPreferenceService.initPreferences(user.user?.userSettings?.tableColumnPreference); this.visibleColumns = this.columnPreferenceService.visibleColumns; - const override = this.entityViewPreferences?.overrides?.find(o => o.entityType?.toUpperCase() === currentEntityTypeStr && o.entityId === this.entity?.id @@ -289,7 +306,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { direction: SortDirection.DESCENDING }; - const fromParam = paramMap.get(QUERY_PARAMS.FROM); + const fromParam = queryParamMap.get(QUERY_PARAMS.FROM); this.currentViewMode = fromParam === 'toggle' ? (viewParam === VIEW_MODES.TABLE || viewParam === VIEW_MODES.GRID ? viewParam @@ -306,8 +323,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { const queryParams: any = { [QUERY_PARAMS.VIEW]: this.currentViewMode, [QUERY_PARAMS.SORT]: this.bookSorter.selectedSort.field, - [QUERY_PARAMS.DIRECTION]: this.bookSorter.selectedSort.direction === SortDirection.ASCENDING ? SORT_DIRECTION.ASCENDING : SORT_DIRECTION.DESCENDING, - [QUERY_PARAMS.SIDEBAR]: this.showFilter.toString() + [QUERY_PARAMS.DIRECTION]: this.bookSorter.selectedSort.direction === SortDirection.ASCENDING ? SORT_DIRECTION.ASCENDING : SORT_DIRECTION.DESCENDING }; if (Object.keys(parsedFilters).length > 0) { @@ -316,29 +332,13 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { const currentParams = this.activatedRoute.snapshot.queryParams; const changed = Object.keys(queryParams).some(k => currentParams[k] !== queryParams[k]); - if (changed) { + const mergedParams = {...currentParams, ...queryParams}; this.router.navigate([], { - queryParams, + queryParams: mergedParams, replaceUrl: true }); } - - this.changeDetectorRef.detectChanges(); - }); - - this.bookFilterComponent.filterSelected.subscribe((filters: Record | null) => { - if (this.settingFiltersFromUrl) return; - - this.selectedFilter.next(filters); - this.rawFilterParamFromUrl = null; - - const hasSidebarFilters = !!filters && Object.keys(filters).length > 0; - this.currentFilterLabel = hasSidebarFilters ? 'All Books (Filtered)' : 'All Books'; - }); - - this.bookFilterComponent.filterModeChanged.subscribe((mode: 'and' | 'or') => { - this.selectedFilterMode.next(mode); }); this.searchTerm$.subscribe(term => { @@ -346,6 +346,25 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { }); } + onFilterSelected(filters: Record | null): void { + if (this.settingFiltersFromUrl) return; + + this.selectedFilter.next(filters); + this.rawFilterParamFromUrl = null; + + const hasSidebarFilters = !!filters && Object.keys(filters).length > 0; + this.currentFilterLabel = hasSidebarFilters ? 'All Books (Filtered)' : 'All Books'; + } + + onFilterModeChanged(mode: 'and' | 'or'): void { + this.selectedFilterMode.next(mode); + } + + toggleSidebar(): void { + this.showFilter = !this.showFilter; + this.sidebarFilterTogglePrefService.selectedShowFilter = this.showFilter; + } + updateScale(): void { this.coverScalePreferenceService.setScale(this.coverScalePreferenceService.scaleFactor); } @@ -476,18 +495,6 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { this.clearSearch(); } - toggleFilterSidebar() { - this.showFilter = !this.showFilter; - const currentParams = this.activatedRoute.snapshot.queryParams; - this.router.navigate([], { - queryParams: { - ...currentParams, - [QUERY_PARAMS.SIDEBAR]: this.showFilter.toString() - }, - replaceUrl: true - }); - } - toggleTableGrid(): void { this.currentViewMode = this.currentViewMode === VIEW_MODES.GRID ? VIEW_MODES.TABLE : VIEW_MODES.GRID; this.router.navigate([], { diff --git a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts index b3a1745ee..4e342f22a 100644 --- a/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts +++ b/booklore-ui/src/app/book/components/book-browser/book-filter/book-filter.component.ts @@ -1,5 +1,5 @@ -import {Component, EventEmitter, ChangeDetectionStrategy, inject, Input, OnDestroy, OnInit, Output} from '@angular/core'; -import {combineLatest, Observable, of, Subject, debounceTime, distinctUntilChanged, takeUntil, shareReplay} from 'rxjs'; +import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output} from '@angular/core'; +import {combineLatest, distinctUntilChanged, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs'; import {map} from 'rxjs/operators'; import {BookService} from '../../../service/book.service'; import {Library} from '../../../model/library.model'; @@ -152,7 +152,7 @@ export class BookFilterComponent implements OnInit, OnDestroy { private filterChangeSubject = new Subject | null>(); @Output() filterSelected = new EventEmitter | null>(); - @Output('filterMode') filterModeChanged = new EventEmitter<'and' | 'or'>(); + @Output() filterModeChanged = new EventEmitter<'and' | 'or'>(); @Input() entity$!: Observable | undefined; @Input() entityType$!: Observable | undefined; @@ -204,8 +204,8 @@ export class BookFilterComponent implements OnInit, OnDestroy { this.filterStreams = { // Temporarily disabled until we can optimize for large libraries /*author: this.getFilterStream((book: Book) => book.metadata?.authors!.map(name => ({id: name, name})) || [], 'id', 'name', sortMode), - category: this.getFilterStream((book: Book) => book.metadata?.categories!.map(name => ({id: name, name})) || [], 'id', 'name', sortMode), - series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name', sortMode),*/ + category: this.getFilterStream((book: Book) => book.metadata?.categories!.map(name => ({id: name, name})) || [], 'id', 'name', sortMode),*/ + series: this.getFilterStream((book) => (book.metadata?.seriesName ? [{id: book.metadata.seriesName, name: book.metadata.seriesName}] : []), 'id', 'name', sortMode), publisher: this.getFilterStream((book) => (book.metadata?.publisher ? [{id: book.metadata.publisher, name: book.metadata.publisher}] : []), 'id', 'name', sortMode), readStatus: this.getFilterStream((book: Book) => { let status = book.readStatus; @@ -231,7 +231,6 @@ export class BookFilterComponent implements OnInit, OnDestroy { }); this.filterChangeSubject.pipe( - debounceTime(10), takeUntil(this.destroy$) ).subscribe(value => this.filterSelected.emit(value)); diff --git a/booklore-ui/src/app/book/components/book-browser/filters/HeaderFilter.ts b/booklore-ui/src/app/book/components/book-browser/filters/HeaderFilter.ts index 6de686043..6fef337ab 100644 --- a/booklore-ui/src/app/book/components/book-browser/filters/HeaderFilter.ts +++ b/booklore-ui/src/app/book/components/book-browser/filters/HeaderFilter.ts @@ -1,7 +1,7 @@ import {BookFilter} from './BookFilter'; import {BookState} from '../../../model/state/book-state.model'; -import {Observable} from 'rxjs'; -import {map, debounceTime, distinctUntilChanged} from 'rxjs/operators'; +import {Observable, of} from 'rxjs'; +import {map, debounceTime, distinctUntilChanged, switchMap} from 'rxjs/operators'; export class HeaderFilter implements BookFilter { @@ -13,30 +13,35 @@ export class HeaderFilter implements BookFilter { str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); return this.searchTerm$.pipe( - debounceTime(500), distinctUntilChanged(), - map(term => { - const normalizedTerm = normalize(term || ''); - if (normalizedTerm && normalizedTerm.trim() !== '') { - const filteredBooks = bookState.books?.filter(book => { - const title = book.metadata?.title || ''; - const series = book.metadata?.seriesName || ''; - const authors = book.metadata?.authors || []; - const categories = book.metadata?.categories || []; - const isbn = book.metadata?.isbn10 || ''; - const isbn13 = book.metadata?.isbn13 || ''; - - const matchesTitle = normalize(title).includes(normalizedTerm); - const matchesSeries = normalize(series).includes(normalizedTerm); - const matchesAuthor = authors.some(author => normalize(author).includes(normalizedTerm)); - const matchesCategory = categories.some(category => normalize(category).includes(normalizedTerm)); - const matchesIsbn = normalize(isbn).includes(normalizedTerm) || normalize(isbn13).includes(normalizedTerm); - - return matchesTitle || matchesSeries || matchesAuthor || matchesCategory || matchesIsbn; - }) || null; - return {...bookState, books: filteredBooks}; + switchMap(term => { + const normalizedTerm = normalize(term || '').trim(); + if (!normalizedTerm) { + return of(bookState); } - return bookState; + return of(normalizedTerm).pipe( + debounceTime(500), + map(nTerm => { + const filteredBooks = bookState.books?.filter(book => { + const title = book.metadata?.title || ''; + const series = book.metadata?.seriesName || ''; + const authors = book.metadata?.authors || []; + const categories = book.metadata?.categories || []; + const isbn = book.metadata?.isbn10 || ''; + const isbn13 = book.metadata?.isbn13 || ''; + + const matchesTitle = normalize(title).includes(nTerm); + const matchesSeries = normalize(series).includes(nTerm); + const matchesAuthor = authors.some(author => normalize(author).includes(nTerm)); + const matchesCategory = categories.some(category => normalize(category).includes(nTerm)); + const matchesIsbn = normalize(isbn).includes(nTerm) || normalize(isbn13).includes(nTerm); + + return matchesTitle || matchesSeries || matchesAuthor || matchesCategory || matchesIsbn; + }) || null; + + return {...bookState, books: filteredBooks}; + }) + ); }) ); } diff --git a/booklore-ui/src/app/book/components/book-browser/filters/sidebar-filter-toggle-pref-service.ts b/booklore-ui/src/app/book/components/book-browser/filters/sidebar-filter-toggle-pref-service.ts new file mode 100644 index 000000000..9ce251369 --- /dev/null +++ b/booklore-ui/src/app/book/components/book-browser/filters/sidebar-filter-toggle-pref-service.ts @@ -0,0 +1,56 @@ +import {inject, Injectable} from '@angular/core'; +import {BehaviorSubject} from 'rxjs'; +import {MessageService} from 'primeng/api'; +import {LocalStorageService} from '../../../../core/service/local-storage-service'; + +@Injectable({ + providedIn: 'root' +}) +export class SidebarFilterTogglePrefService { + + private readonly STORAGE_KEY = 'showSidebarFilter'; + private readonly messageService = inject(MessageService); + private readonly localStorageService = inject(LocalStorageService); + + private readonly showFilterSubject = new BehaviorSubject(true); + readonly showFilter$ = this.showFilterSubject.asObservable(); + + constructor() { + this.loadFromStorage(); + } + + get selectedShowFilter(): boolean { + return this.showFilterSubject.value; + } + + set selectedShowFilter(value: boolean) { + if (this.showFilterSubject.value !== value) { + this.showFilterSubject.next(value); + this.savePreference(value); + } + } + + toggle(): void { + this.selectedShowFilter = !this.selectedShowFilter; + } + + private savePreference(value: boolean): void { + try { + this.localStorageService.set(this.STORAGE_KEY, value); + } catch (e) { + this.messageService.add({ + severity: 'error', + summary: 'Save Failed', + detail: 'Could not save sidebar filter preference locally.', + life: 3000 + }); + } + } + + private loadFromStorage(): void { + const saved = this.localStorageService.get(this.STORAGE_KEY); + if (saved !== null) { + this.showFilterSubject.next(saved); + } + } +}