From a7fe7b555d51c96c989f3c0846ff6f0d02143d99 Mon Sep 17 00:00:00 2001 From: Muppetteer Date: Thu, 4 Dec 2025 15:19:52 +1100 Subject: [PATCH] Feature: filter mode preference (#1739) * Feature: filter mode preference * Flter sorting mode preference * Filter sorting mode preference --------- Co-authored-by: Aditya Chandel <8075870+adityachandelgit@users.noreply.github.com> --- .../custom/BookLoreUserTransformer.java | 1 + .../booklore/model/dto/BookLoreUser.java | 1 + .../model/dto/settings/UserSettingKey.java | 2 +- .../user/DefaultUserSettingsProvider.java | 3 +- .../book-browser/book-browser.component.ts | 10 +- .../book-filter/book-filter.component.ts | 26 ++-- .../book-browser/filters/SidebarFilter.ts | 3 +- .../settings/user-management/user.service.ts | 5 +- .../filter-preferences.component.html | 67 +++++++++ .../filter-preferences.component.scss | 133 ++++++++++++++++++ .../filter-preferences.component.ts | 90 ++++++++++++ .../view-preferences-parent.component.html | 4 + .../view-preferences-parent.component.ts | 4 +- 13 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.html create mode 100644 booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.scss create mode 100644 booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.ts diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java index ff8172ce..cda71a38 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java @@ -67,6 +67,7 @@ public class BookLoreUserTransformer { } } else { switch (settingKey) { + case FILTER_MODE -> userSettings.setFilterMode(value); case FILTER_SORTING_MODE -> userSettings.setFilterSortingMode(value); case METADATA_CENTER_VIEW_MODE -> userSettings.setMetadataCenterViewMode(value); case ENABLE_SERIES_VIEW -> userSettings.setEnableSeriesView(Boolean.parseBoolean(value)); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index 36c7bcb0..a039a5bf 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -49,6 +49,7 @@ public class BookLoreUser { public SidebarSortOption sidebarShelfSorting; public EntityViewPreferences entityViewPreferences; public List tableColumnPreference; + public String filterMode; public String filterSortingMode; public String metadataCenterViewMode; public boolean koReaderEnabled; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java index 73ad2fcf..0c534a52 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/UserSettingKey.java @@ -14,7 +14,7 @@ public enum UserSettingKey { ENTITY_VIEW_PREFERENCES("entityViewPreferences", true), TABLE_COLUMN_PREFERENCE("tableColumnPreference", true), DASHBOARD_CONFIG("dashboardConfig", true), - + FILTER_MODE("filterMode", false), FILTER_SORTING_MODE("filterSortingMode", false), METADATA_CENTER_VIEW_MODE("metadataCenterViewMode", false), ENABLE_SERIES_VIEW("enableSeriesView", false); 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 46fd5c80..70ecb841 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 @@ -30,7 +30,8 @@ public class DefaultUserSettingsProvider { defaultSettings.put(UserSettingKey.SIDEBAR_SHELF_SORTING, this::buildDefaultSidebarShelfSorting); defaultSettings.put(UserSettingKey.ENTITY_VIEW_PREFERENCES, this::buildDefaultEntityViewPreferences); defaultSettings.put(UserSettingKey.TABLE_COLUMN_PREFERENCE, () -> null); - defaultSettings.put(UserSettingKey.FILTER_SORTING_MODE, () -> "count"); + defaultSettings.put(UserSettingKey.FILTER_MODE, () -> "and"); + defaultSettings.put(UserSettingKey.FILTER_SORTING_MODE, () -> "alphabetical"); defaultSettings.put(UserSettingKey.METADATA_CENTER_VIEW_MODE, () -> "route"); } 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 ee2d76cf..9b9dc07b 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 @@ -25,9 +25,9 @@ import {ProgressSpinner} from 'primeng/progressspinner'; import {Menu} from 'primeng/menu'; import {InputText} from 'primeng/inputtext'; import {FormsModule} from '@angular/forms'; -import {BookFilterComponent, BookFilterMode} from './book-filter/book-filter.component'; +import {BookFilterComponent} from './book-filter/book-filter.component'; import {Tooltip} from 'primeng/tooltip'; -import {EntityViewPreferences, UserService} from '../../../settings/user-management/user.service'; +import {BookFilterMode, EntityViewPreferences, UserService} from '../../../settings/user-management/user.service'; import {SeriesCollapseFilter} from './filters/SeriesCollapseFilter'; import {SideBarFilter} from './filters/SidebarFilter'; import {HeaderFilter} from './filters/HeaderFilter'; @@ -267,12 +267,12 @@ export class BookBrowserComponent implements OnInit, AfterViewInit { const sortParam = queryParamMap.get(QUERY_PARAMS.SORT); const directionParam = queryParamMap.get(QUERY_PARAMS.DIRECTION); const filterParams = queryParamMap.get(QUERY_PARAMS.FILTER); - const filterMode = queryParamMap.get(QUERY_PARAMS.FMODE); + const filterMode = queryParamMap.get(QUERY_PARAMS.FMODE) || user.user?.userSettings?.filterMode; if (filterMode && filterMode !== this.selectedFilterMode.getValue()) { - this.selectedFilterMode.next(filterMode); + this.selectedFilterMode.next(filterMode); if (this.bookFilterComponent) { - this.bookFilterComponent.selectedFilterMode = filterMode; + this.bookFilterComponent.selectedFilterMode = filterMode; } } diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts index c6d03053..65743fe4 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/book-filter/book-filter.component.ts @@ -1,5 +1,5 @@ import {ChangeDetectionStrategy, Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output} from '@angular/core'; -import {combineLatest, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs'; +import {combineLatest, filter, Observable, of, shareReplay, Subject, takeUntil} from 'rxjs'; import {map} from 'rxjs/operators'; import {BookService} from '../../../service/book.service'; import {Library} from '../../../model/library.model'; @@ -11,15 +11,13 @@ import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common'; import {Badge} from 'primeng/badge'; import {FormsModule} from '@angular/forms'; import {SelectButton} from 'primeng/selectbutton'; -import {UserService} from '../../../../settings/user-management/user.service'; +import {BookFilterMode, FilterSortingMode, UserService, UserState} from '../../../../settings/user-management/user.service'; import {MagicShelf} from '../../../../magic-shelf/service/magic-shelf.service'; import {BookRuleEvaluatorService} from '../../../../magic-shelf/service/book-rule-evaluator.service'; import {GroupRule} from '../../../../magic-shelf/component/magic-shelf-component'; type Filter = { value: T; bookCount: number }; -export type BookFilterMode = 'and' | 'or' | 'single'; - export const ratingRanges = [ {id: '0to1', label: '0 to 1', min: 0, max: 1, sortIndex: 0}, {id: '1to2', label: '1 to 2', min: 1, max: 2, sortIndex: 1}, @@ -200,14 +198,23 @@ export class BookFilterComponent implements OnInit, OnDestroy { bookService = inject(BookService); userService = inject(UserService); bookRuleEvaluatorService = inject(BookRuleEvaluatorService); + userData$: Observable = this.userService.userState$; + filterSortingMode: FilterSortingMode = 'alphabetical'; ngOnInit(): void { + this.userData$.pipe( + filter(userState => !!userState?.user && userState.loaded), + takeUntil(this.destroy$) + ).subscribe(userState => { + this.filterSortingMode = userState.user!.userSettings.filterSortingMode ?? 'alphabetical'; + }); + combineLatest([ this.entity$ ?? of(null), this.entityType$ ?? of(EntityType.ALL_BOOKS) ]) .pipe(takeUntil(this.destroy$)) - .subscribe(([sortMode]) => { + .subscribe(() => { this.filterStreams = { author: this.getFilterStream( (book: Book) => Array.isArray(book.metadata?.authors) ? book.metadata.authors.map(name => ({id: name, name})) : [], @@ -270,7 +277,7 @@ export class BookFilterComponent implements OnInit, OnDestroy { extractor: (book: Book) => T[] | undefined, idKey: keyof T, nameKey: keyof T, - sortMode: 'count' | 'alphabetical' | 'sortIndex' = 'count' + sortMode: FilterSortingMode | 'sortIndex' = this.filterSortingMode ): Observable[]> { return combineLatest([ this.bookService.bookState$, @@ -296,11 +303,10 @@ export class BookFilterComponent implements OnInit, OnDestroy { const sorted = result.sort((a, b) => { if (sortMode === 'sortIndex') { return (a.value.sortIndex ?? 999) - (b.value.sortIndex ?? 999); + } else if (sortMode === 'count' && b.bookCount !== a.bookCount) { + return b.bookCount - a.bookCount; } - return ( - b.bookCount - a.bookCount || - a.value[nameKey].toString().localeCompare(b.value[nameKey].toString()) - ); + return a.value[nameKey].toString().localeCompare(b.value[nameKey].toString()); }); const isTruncated = sorted.length > 500; diff --git a/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts b/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts index ab28f4d8..f2288a7e 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts +++ b/booklore-ui/src/app/features/book/components/book-browser/filters/SidebarFilter.ts @@ -2,8 +2,9 @@ import {Observable, combineLatest, of} from 'rxjs'; import {map} from 'rxjs/operators'; import {BookFilter} from './BookFilter'; import {BookState} from '../../../model/state/book-state.model'; -import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges, BookFilterMode} from '../book-filter/book-filter.component'; +import {fileSizeRanges, matchScoreRanges, pageCountRanges, ratingRanges} from '../book-filter/book-filter.component'; import {Book, ReadStatus} from '../../../model/book.model'; +import {BookFilterMode} from '../../../../settings/user-management/user.service'; export function isRatingInRange(rating: number | undefined | null, rangeId: string): boolean { if (rating == null) return false; diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.ts b/booklore-ui/src/app/features/settings/user-management/user.service.ts index af4b9dc8..cf1d827e 100644 --- a/booklore-ui/src/app/features/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/features/settings/user-management/user.service.ts @@ -43,6 +43,8 @@ export interface PerBookSetting { } export type PageSpread = 'off' | 'even' | 'odd'; +export type BookFilterMode = 'and' | 'or' | 'single'; +export type FilterSortingMode = 'alphabetical' | 'count'; export enum CbxBackgroundColor { GRAY = 'GRAY', @@ -127,7 +129,8 @@ export interface UserSettings { newPdfReaderSetting: NewPdfReaderSetting; sidebarLibrarySorting: SidebarLibrarySorting; sidebarShelfSorting: SidebarShelfSorting; - filterSortingMode: 'alphabetical' | 'count'; + filterMode: BookFilterMode; + filterSortingMode: FilterSortingMode; metadataCenterViewMode: 'route' | 'dialog'; enableSeriesView: boolean; entityViewPreferences: EntityViewPreferences; diff --git a/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.html b/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.html new file mode 100644 index 00000000..ba35e39b --- /dev/null +++ b/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.html @@ -0,0 +1,67 @@ +
+
+

+ + Filter Preferences +

+

+ Configure default options for filter controls shown in the sidebar. +

+
+ +
+
+
+
+
+
+ + + +
+

+ Choose the default mode for combining filters selected from the filter sidebar. +

+
+
+ +
+
+
+ + + +
+

+ Choose how filter options are sorted in the sidebar. +

+
+
+
+
+
+
diff --git a/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.scss b/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.scss new file mode 100644 index 00000000..046b2822 --- /dev/null +++ b/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.scss @@ -0,0 +1,133 @@ +.main-container { + width: 100%; + padding: 1rem; + overflow-y: auto; +} + +.enclosing-container { + border-color: var(--p-content-border-color); + background: var(--p-content-background); +} + +.settings-header { + margin-top: 1rem; + margin-bottom: 2rem; +} + +.settings-title { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1.25rem; + font-weight: 700; + color: var(--p-text-color); + margin: 0 0 0.75rem 0; + + .pi { + color: var(--p-primary-color); + font-size: 1.25rem; + } +} + +.settings-description { + color: var(--p-text-muted-color); + font-size: 0.875rem; + line-height: 1.5; + margin-bottom: 1rem; +} + +.settings-content { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.preferences-section { + @media (min-width: 768px) { + padding: 0 1rem; + } +} + +.settings-card { + border: 1px solid var(--p-content-border-color); + border-radius: 8px; + background: var(--p-content-background); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.setting-item { + display: flex; + align-items: flex-start; + gap: 2rem; + padding: 0.25rem 0; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + + @media (max-width: 768px) { + flex-direction: column; + gap: 1rem; + } +} + +.setting-info { + flex: 1; + min-width: 0; + + .setting-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: var(--p-text-color); + font-size: 1rem; + margin-bottom: 0.5rem; + } + + .setting-label-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; + + .setting-label { + margin-bottom: 0; + flex-shrink: 0; + min-width: 120px; + } + + p-select { + min-width: 200px; + + @media (max-width: 768px) { + min-width: 180px; + } + } + } + + .setting-description { + color: var(--p-text-muted-color); + font-size: 0.875rem; + line-height: 1.5; + margin: 0; + } +} + +.setting-control { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: flex-end; + + @media (max-width: 768px) { + align-items: flex-start; + width: 100%; + } +} diff --git a/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.ts b/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.ts new file mode 100644 index 00000000..0c352c44 --- /dev/null +++ b/booklore-ui/src/app/features/settings/view-preferences-parent/filter-preferences/filter-preferences.component.ts @@ -0,0 +1,90 @@ +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {Select} from 'primeng/select'; +import {Tooltip} from 'primeng/tooltip'; +import {BookFilterMode, FilterSortingMode, SidebarLibrarySorting, SidebarShelfSorting, User, UserService, UserSettings, UserState} from '../../user-management/user.service'; +import {MessageService} from 'primeng/api'; +import {Observable, Subject} from 'rxjs'; +import {FormsModule} from '@angular/forms'; +import {filter, takeUntil} from 'rxjs/operators'; + +@Component({ + selector: 'app-filter-preferences', + imports: [ + Select, + Tooltip, + FormsModule + ], + templateUrl: './filter-preferences.component.html', + styleUrl: './filter-preferences.component.scss' +}) +export class FilterPreferencesComponent implements OnInit, OnDestroy { + + readonly filterModes = [ + {label: 'And', value: 'and'}, + {label: 'Or', value: 'or'}, + {label: 'Single', value: 'single'}, + ]; + + readonly filterSortingModes = [ + {label: 'Alphabetical', value: 'alphabetical'}, + {label: 'By Count', value: 'count'}, + ]; + + selectedFilterMode: BookFilterMode = 'and'; + selectedFilterSortingMode: FilterSortingMode = 'alphabetical'; + + private readonly userService = inject(UserService); + private readonly messageService = inject(MessageService); + private readonly destroy$ = new Subject(); + + userData$: Observable = this.userService.userState$; + private currentUser: User | null = null; + + ngOnInit(): void { + this.userData$.pipe( + filter(userState => !!userState?.user && userState.loaded), + takeUntil(this.destroy$) + ).subscribe(userState => { + this.currentUser = userState.user; + this.loadPreferences(userState.user!.userSettings); + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private loadPreferences(settings: UserSettings): void { + this.selectedFilterMode = settings.filterMode ?? 'and'; + this.selectedFilterSortingMode = settings.filterSortingMode ?? 'alphabetical'; + } + + private updatePreference(path: string[], value: any): void { + if (!this.currentUser) return; + + let target: any = this.currentUser.userSettings; + for (let i = 0; i < path.length - 1; i++) { + target = target[path[i]] ||= {}; + } + target[path.at(-1)!] = value; + + const [rootKey] = path; + const updatedValue = this.currentUser.userSettings[rootKey as keyof UserSettings]; + this.userService.updateUserSetting(this.currentUser.id, rootKey, updatedValue); + this.messageService.add({ + severity: 'success', + summary: 'Preferences Updated', + detail: 'Your preferences have been saved successfully.', + life: 1500 + }); + } + + onFilterModeChange() { + this.updatePreference(['filterMode'], this.selectedFilterMode); + } + + onFilterSortingModeChange() { + this.updatePreference(['filterSortingMode'], this.selectedFilterSortingMode); + } +} diff --git a/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.html b/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.html index cdd84482..bb3e83c5 100644 --- a/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.html +++ b/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.html @@ -3,6 +3,10 @@
+ +
+ +
diff --git a/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.ts b/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.ts index 95e03e25..1ab70926 100644 --- a/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.ts +++ b/booklore-ui/src/app/features/settings/view-preferences-parent/view-preferences-parent.component.ts @@ -6,6 +6,7 @@ import {ToastModule} from 'primeng/toast'; import {ViewPreferencesComponent} from './view-preferences/view-preferences.component'; import {SidebarSortingPreferencesComponent} from './sidebar-sorting-preferences/sidebar-sorting-preferences.component'; import {MetaCenterViewModeComponent} from './meta-center-view-mode/meta-center-view-mode-component'; +import {FilterPreferencesComponent} from './filter-preferences/filter-preferences.component'; @Component({ selector: 'app-view-preferences-parent', @@ -17,7 +18,8 @@ import {MetaCenterViewModeComponent} from './meta-center-view-mode/meta-center-v ToastModule, ViewPreferencesComponent, SidebarSortingPreferencesComponent, - MetaCenterViewModeComponent + MetaCenterViewModeComponent, + FilterPreferencesComponent, ], templateUrl: './view-preferences-parent.component.html', styleUrl: './view-preferences-parent.component.scss'