Persist sidebar filter state when toggling visibility

This commit is contained in:
aditya.chandel
2025-08-25 14:15:47 -06:00
committed by Aditya Chandel
parent 0e4fb47580
commit 11289adf79
5 changed files with 192 additions and 114 deletions

View File

@@ -44,7 +44,8 @@
<i
class="pi pi-filter-slash"
pTooltip="Clear applied filters"
tooltipPosition="top">
tooltipPosition="top"
style="color: orange;">
</i>
</a>
}
@@ -83,7 +84,6 @@
</p-popover>
</div>
}
<div>
<a
class="topbar-items topbar-item"
@@ -117,6 +117,15 @@
{{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x
</div>
</div>
<!--<div class="flex flex-col gap-2">
<label class="font-medium text-sm">Show Sidebar Filters:</label>
<div class="flex gap-4 justify-start">
<p-toggle-switch
[(ngModel)]="sidebarFilterTogglePrefService.selectedShowFilter"
(onChange)="sidebarFilterTogglePrefService.selectedShowFilter = $event.checked">
</p-toggle-switch>
</div>
</div>-->
<div class="flex flex-col gap-2">
<label class="font-medium text-sm">Sidebar Filter Sort:</label>
<div class="flex gap-4 justify-start">
@@ -217,15 +226,13 @@
}
</div>
<p-button
class="pr-2"
icon="pi pi-chevron-right"
[outlined]="true"
[rounded]="true"
(click)="toggleFilterSidebar()"
pTooltip="Toggle filter sidebar"
tooltipPosition="top"
/>
<a class="topbar-items topbar-item" (click)="toggleSidebar()">
<i
[ngClass]="showFilter ? 'pi pi-angle-double-right' : 'pi pi-angle-double-left'"
pTooltip="Toggle sidebar filters"
tooltipPosition="top">
</i>
</a>
</div>
</div>
@@ -396,19 +403,23 @@
}
</div>
@if (this.showFilter) {
<div
class="mobile-filter-mask"
(click)="this.showFilter = false">
</div>
<app-book-filter
[showFilter]="showFilter"
class="filter-overlay-container z-50 flex-shrink-0"
[ngClass]="{ 'active': showFilter, 'ml-4': showFilter }"
[entity$]="entity$"
[entityType$]="entityType$"
[resetFilter$]="resetFilterSubject"
(filterSelected)="onFilterSelected($event)"
(filterModeChanged)="onFilterModeChanged($event)">
</app-book-filter>
}
<app-book-filter
[showFilter]="showFilter"
class="filter-overlay-container z-50 flex-shrink-0"
[ngClass]="{ 'active': showFilter, 'ml-4': showFilter }"
[entity$]="entity$"
[entityType$]="entityType$"
[resetFilter$]="resetFilterSubject">
</app-book-filter>
</div>

View File

@@ -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<string, string[]> = {};
@@ -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<string, any> | 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<string, any> | 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([], {

View File

@@ -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<Record<string, any> | null>();
@Output() filterSelected = new EventEmitter<Record<string, any> | null>();
@Output('filterMode') filterModeChanged = new EventEmitter<'and' | 'or'>();
@Output() filterModeChanged = new EventEmitter<'and' | 'or'>();
@Input() entity$!: Observable<Library | Shelf | MagicShelf | null> | undefined;
@Input() entityType$!: Observable<EntityType> | 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));

View File

@@ -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};
})
);
})
);
}

View File

@@ -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<boolean>(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<boolean>(this.STORAGE_KEY);
if (saved !== null) {
this.showFilterSubject.next(saved);
}
}
}