mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(sorting): add multi-field sorting support (#2628)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {Injectable, inject} from '@angular/core';
|
||||
import {ActivatedRoute, ParamMap, Router} from '@angular/router';
|
||||
import {SortDirection, SortOption} from '../../model/sort.model';
|
||||
import {BookFilterMode, EntityViewPreferences} from '../../../settings/user-management/user.service';
|
||||
import {BookFilterMode, EntityViewPreference, EntityViewPreferences, SortCriterion} from '../../../settings/user-management/user.service';
|
||||
import {EntityType} from './book-browser.component';
|
||||
|
||||
export const QUERY_PARAMS = {
|
||||
@@ -35,6 +35,7 @@ export interface BookBrowserQueryState {
|
||||
export interface QueryParseResult {
|
||||
viewMode: string;
|
||||
sortOption: SortOption;
|
||||
sortCriteria: SortOption[];
|
||||
filters: Record<string, string[]>;
|
||||
filterMode: BookFilterMode;
|
||||
viewModeFromToggle: boolean;
|
||||
@@ -73,32 +74,71 @@ export class BookBrowserQueryParamsService {
|
||||
o.entityId === entityId
|
||||
);
|
||||
|
||||
const effectivePrefs = override?.preferences ?? globalPrefs ?? {
|
||||
const effectivePrefs: EntityViewPreference = override?.preferences ?? globalPrefs ?? {
|
||||
sortKey: 'addedOn',
|
||||
sortDir: 'ASC',
|
||||
view: 'GRID'
|
||||
view: 'GRID',
|
||||
coverSize: 1.0,
|
||||
seriesCollapsed: false,
|
||||
overlayBookType: true
|
||||
};
|
||||
|
||||
const userSortKey = effectivePrefs.sortKey;
|
||||
const userSortDir = effectivePrefs.sortDir?.toUpperCase() === 'DESC'
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING;
|
||||
// Parse sort criteria - supports both legacy and new multi-sort format
|
||||
let sortCriteria: SortOption[];
|
||||
|
||||
const effectiveSortKey = sortParam || userSortKey;
|
||||
const effectiveSortDir = directionParam
|
||||
? (directionParam.toLowerCase() === SORT_DIRECTION.DESCENDING ? SortDirection.DESCENDING : SortDirection.ASCENDING)
|
||||
: userSortDir;
|
||||
if (sortParam) {
|
||||
// Check if it's new multi-sort format (contains colons like "author:asc,title:desc")
|
||||
if (sortParam.includes(':')) {
|
||||
sortCriteria = this.deserializeSort(sortParam, sortOptions);
|
||||
} else {
|
||||
// Legacy format: separate sort and direction params
|
||||
const effectiveSortDir = directionParam
|
||||
? (directionParam.toLowerCase() === SORT_DIRECTION.DESCENDING ? SortDirection.DESCENDING : SortDirection.ASCENDING)
|
||||
: SortDirection.ASCENDING;
|
||||
const matchedSort = sortOptions.find(opt => opt.field === sortParam);
|
||||
sortCriteria = matchedSort ? [{
|
||||
label: matchedSort.label,
|
||||
field: matchedSort.field,
|
||||
direction: effectiveSortDir
|
||||
}] : [];
|
||||
}
|
||||
} else {
|
||||
// Use user preferences
|
||||
if (effectivePrefs.sortCriteria && effectivePrefs.sortCriteria.length > 0) {
|
||||
sortCriteria = effectivePrefs.sortCriteria.map((c: SortCriterion) => {
|
||||
const matchedSort = sortOptions.find(opt => opt.field === c.field);
|
||||
return {
|
||||
label: matchedSort?.label ?? c.field,
|
||||
field: c.field,
|
||||
direction: c.direction === 'DESC' ? SortDirection.DESCENDING : SortDirection.ASCENDING
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Fall back to legacy single sort preference
|
||||
const userSortKey = effectivePrefs.sortKey;
|
||||
const userSortDir = effectivePrefs.sortDir?.toUpperCase() === 'DESC'
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING;
|
||||
const matchedSort = sortOptions.find(opt => opt.field === userSortKey);
|
||||
sortCriteria = matchedSort ? [{
|
||||
label: matchedSort.label,
|
||||
field: matchedSort.field,
|
||||
direction: userSortDir
|
||||
}] : [];
|
||||
}
|
||||
}
|
||||
|
||||
const matchedSort = sortOptions.find(opt => opt.field === effectiveSortKey);
|
||||
const sortOption: SortOption = matchedSort ? {
|
||||
label: matchedSort.label,
|
||||
field: matchedSort.field,
|
||||
direction: effectiveSortDir
|
||||
} : {
|
||||
label: 'Added On',
|
||||
field: 'addedOn',
|
||||
direction: SortDirection.DESCENDING
|
||||
};
|
||||
// Ensure we have at least a default sort
|
||||
if (sortCriteria.length === 0) {
|
||||
sortCriteria = [{
|
||||
label: 'Added On',
|
||||
field: 'addedOn',
|
||||
direction: SortDirection.DESCENDING
|
||||
}];
|
||||
}
|
||||
|
||||
// For backward compatibility, expose the first sort as sortOption
|
||||
const sortOption = sortCriteria[0];
|
||||
|
||||
// Determine view mode
|
||||
const viewModeFromToggle = fromParam === 'toggle';
|
||||
@@ -111,6 +151,7 @@ export class BookBrowserQueryParamsService {
|
||||
return {
|
||||
viewMode,
|
||||
sortOption,
|
||||
sortCriteria,
|
||||
filters,
|
||||
filterMode,
|
||||
viewModeFromToggle
|
||||
@@ -129,13 +170,15 @@ export class BookBrowserQueryParamsService {
|
||||
}
|
||||
|
||||
updateSort(sortOption: SortOption): void {
|
||||
this.updateMultiSort([sortOption]);
|
||||
}
|
||||
|
||||
updateMultiSort(sortCriteria: SortOption[]): void {
|
||||
const currentParams = this.activatedRoute.snapshot.queryParams;
|
||||
const newParams = {
|
||||
...currentParams,
|
||||
[QUERY_PARAMS.SORT]: sortOption.field,
|
||||
[QUERY_PARAMS.DIRECTION]: sortOption.direction === SortDirection.ASCENDING
|
||||
? SORT_DIRECTION.ASCENDING
|
||||
: SORT_DIRECTION.DESCENDING
|
||||
[QUERY_PARAMS.SORT]: this.serializeSort(sortCriteria),
|
||||
[QUERY_PARAMS.DIRECTION]: null // Remove legacy direction param
|
||||
};
|
||||
|
||||
this.router.navigate([], {
|
||||
@@ -144,6 +187,32 @@ export class BookBrowserQueryParamsService {
|
||||
});
|
||||
}
|
||||
|
||||
serializeSort(criteria: SortOption[]): string {
|
||||
return criteria.map(c =>
|
||||
`${c.field}:${c.direction === SortDirection.ASCENDING ? 'asc' : 'desc'}`
|
||||
).join(',');
|
||||
}
|
||||
|
||||
deserializeSort(sortParam: string, sortOptions: SortOption[]): SortOption[] {
|
||||
const criteria: SortOption[] = [];
|
||||
|
||||
sortParam.split(',').forEach(part => {
|
||||
const [field, dir] = part.split(':');
|
||||
if (field) {
|
||||
const matchedSort = sortOptions.find(opt => opt.field === field);
|
||||
if (matchedSort) {
|
||||
criteria.push({
|
||||
label: matchedSort.label,
|
||||
field: matchedSort.field,
|
||||
direction: dir?.toLowerCase() === 'desc' ? SortDirection.DESCENDING : SortDirection.ASCENDING
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return criteria;
|
||||
}
|
||||
|
||||
updateFilters(filters: Record<string, string[]> | null): void {
|
||||
const queryParam = filters && Object.keys(filters).length > 0
|
||||
? this.serializeFilters(filters)
|
||||
@@ -202,16 +271,14 @@ export class BookBrowserQueryParamsService {
|
||||
|
||||
syncQueryParams(
|
||||
viewMode: string,
|
||||
sortOption: SortOption,
|
||||
sortCriteria: SortOption[],
|
||||
filterMode: BookFilterMode,
|
||||
filters: Record<string, string[]>
|
||||
): void {
|
||||
const queryParams: Record<string, string | number | null | undefined> = {
|
||||
[QUERY_PARAMS.VIEW]: viewMode,
|
||||
[QUERY_PARAMS.SORT]: sortOption.field,
|
||||
[QUERY_PARAMS.DIRECTION]: sortOption.direction === SortDirection.ASCENDING
|
||||
? SORT_DIRECTION.ASCENDING
|
||||
: SORT_DIRECTION.DESCENDING,
|
||||
[QUERY_PARAMS.SORT]: this.serializeSort(sortCriteria),
|
||||
[QUERY_PARAMS.DIRECTION]: null, // Remove legacy direction param
|
||||
[QUERY_PARAMS.FMODE]: filterMode,
|
||||
};
|
||||
|
||||
|
||||
@@ -163,24 +163,27 @@
|
||||
</p-popover>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a class="topbar-items topbar-item" (click)="sortMenu.toggle($event)">
|
||||
<div class="sort-button-wrapper">
|
||||
<a class="topbar-items topbar-item" (click)="sortPopover.toggle($event)">
|
||||
<i
|
||||
class="pi pi-sort"
|
||||
pTooltip="Select sorting"
|
||||
tooltipPosition="top">
|
||||
</i>
|
||||
@if (sortCriteriaCount > 1) {
|
||||
<p-badge [value]="sortCriteriaCount.toString()" class="sort-badge"/>
|
||||
}
|
||||
</a>
|
||||
<p-menu #sortMenu [popup]="true" [model]="this.bookSorter.sortOptions" appendTo="body">
|
||||
<ng-template pTemplate="item" let-item>
|
||||
<div class="sort-menu-item" (click)="this.bookSorter.sortBooks(item.field)">
|
||||
<span>{{ item.label }}</span>
|
||||
@if (item.icon) {
|
||||
<i [class]="item.icon"></i>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-menu>
|
||||
<p-popover #sortPopover [dismissable]="true" appendTo="body">
|
||||
<app-multi-sort-popover
|
||||
[sortCriteria]="this.bookSorter.selectedSortCriteria"
|
||||
[availableSortOptions]="this.bookSorter.sortOptions"
|
||||
(addCriterion)="onAddSortCriterion($event)"
|
||||
(removeCriterion)="onRemoveSortCriterion($event)"
|
||||
(toggleDirection)="onToggleSortDirection($event)"
|
||||
(reorder)="onReorderSortCriteria($event)">
|
||||
</app-multi-sort-popover>
|
||||
</p-popover>
|
||||
</div>
|
||||
|
||||
<a class="topbar-items topbar-item" (click)="toggleTableGrid()">
|
||||
|
||||
@@ -175,7 +175,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Sort menu item
|
||||
// Sort button wrapper
|
||||
.sort-button-wrapper {
|
||||
position: relative;
|
||||
|
||||
.sort-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort menu item (kept for backward compat)
|
||||
.sort-menu-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {AfterViewInit, ApplicationRef, ChangeDetectorRef, Component, HostListener, inject, NgZone, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute, NavigationStart, Router} from '@angular/router';
|
||||
import {ConfirmationService, MenuItem, MessageService, PrimeTemplate} from 'primeng/api';
|
||||
import {ConfirmationService, MenuItem, MessageService} from 'primeng/api';
|
||||
import {PageTitleService} from '../../../../shared/service/page-title.service';
|
||||
import {BookService} from '../../service/book.service';
|
||||
import {debounceTime, filter, map, switchMap, takeUntil} from 'rxjs/operators';
|
||||
@@ -8,7 +8,7 @@ import {BehaviorSubject, combineLatest, finalize, Observable, of, Subject, Subsc
|
||||
import {DynamicDialogRef} from 'primeng/dynamicdialog';
|
||||
import {Library} from '../../model/library.model';
|
||||
import {Shelf} from '../../model/shelf.model';
|
||||
import {SortOption} from '../../model/sort.model';
|
||||
import {SortDirection, SortOption} from '../../model/sort.model';
|
||||
import {BookState} from '../../model/state/book-state.model';
|
||||
import {Book} from '../../model/book.model';
|
||||
import {LibraryShelfMenuService} from '../../service/library-shelf-menu.service';
|
||||
@@ -38,6 +38,7 @@ import {Divider} from 'primeng/divider';
|
||||
import {MultiSelect} from 'primeng/multiselect';
|
||||
import {TableColumnPreferenceService} from './table-column-preference.service';
|
||||
import {TieredMenu} from 'primeng/tieredmenu';
|
||||
import {BadgeModule} from 'primeng/badge';
|
||||
import {BookMenuService} from '../../service/book-menu.service';
|
||||
import {MagicShelf} from '../../../magic-shelf/service/magic-shelf.service';
|
||||
import {SidebarFilterTogglePrefService} from './filters/sidebar-filter-toggle-pref.service';
|
||||
@@ -54,6 +55,9 @@ import {BookBrowserEntityService} from './book-browser-entity.service';
|
||||
import {BookFilterOrchestrationService} from './book-filter-orchestration.service';
|
||||
import {BookBrowserScrollService} from './book-browser-scroll.service';
|
||||
import {AppSettingsService} from '../../../../shared/service/app-settings.service';
|
||||
import {MultiSortPopoverComponent} from './sorting/multi-sort-popover/multi-sort-popover.component';
|
||||
import {CdkDragDrop} from '@angular/cdk/drag-drop';
|
||||
import {SortService} from '../../service/sort.service';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -70,8 +74,8 @@ export enum EntityType {
|
||||
styleUrls: ['./book-browser.component.scss'],
|
||||
imports: [
|
||||
Button, VirtualScrollerModule, BookCardComponent, AsyncPipe, ProgressSpinner, Menu, InputText, FormsModule,
|
||||
BookTableComponent, BookFilterComponent, Tooltip, NgClass, PrimeTemplate, NgStyle, Popover,
|
||||
Checkbox, Slider, Divider, MultiSelect, TieredMenu
|
||||
BookTableComponent, BookFilterComponent, Tooltip, NgClass, NgStyle, Popover,
|
||||
Checkbox, Slider, Divider, MultiSelect, TieredMenu, BadgeModule, MultiSortPopoverComponent
|
||||
],
|
||||
providers: [SeriesCollapseFilter],
|
||||
animations: [
|
||||
@@ -141,7 +145,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
visibleColumns: { field: string; header: string }[] = [];
|
||||
entityViewPreferences: EntityViewPreferences | undefined;
|
||||
currentViewMode: string | undefined;
|
||||
lastAppliedSort: SortOption | null = null;
|
||||
lastAppliedSortCriteria: SortOption[] = [];
|
||||
showFilter = false;
|
||||
screenWidth = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
||||
mobileColumnCount = 3;
|
||||
@@ -162,8 +166,9 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private sideBarFilter = new SideBarFilter(this.selectedFilter, this.selectedFilterMode);
|
||||
private headerFilter = new HeaderFilter(this.searchTerm$);
|
||||
protected bookSorter = new BookSorter(
|
||||
selectedSort => this.onManualSortChange(selectedSort)
|
||||
sortCriteria => this.onMultiSortChange(sortCriteria)
|
||||
);
|
||||
private sortService = inject(SortService);
|
||||
|
||||
private bookStateSubscription: Subscription | undefined;
|
||||
|
||||
@@ -444,20 +449,18 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.visibleColumns = this.columnPreferenceService.visibleColumns;
|
||||
|
||||
|
||||
this.bookSorter.selectedSort = parseResult.sortOption;
|
||||
this.bookSorter.setSortCriteria(parseResult.sortCriteria);
|
||||
this.currentViewMode = parseResult.viewMode;
|
||||
this.bookSorter.updateSortOptions();
|
||||
|
||||
if (this.lastAppliedSort?.field !== this.bookSorter.selectedSort.field ||
|
||||
this.lastAppliedSort?.direction !== this.bookSorter.selectedSort.direction) {
|
||||
this.lastAppliedSort = {...this.bookSorter.selectedSort};
|
||||
this.applySortOption(this.bookSorter.selectedSort);
|
||||
if (!this.areSortCriteriaEqual(this.lastAppliedSortCriteria, this.bookSorter.selectedSortCriteria)) {
|
||||
this.lastAppliedSortCriteria = [...this.bookSorter.selectedSortCriteria];
|
||||
this.applySortCriteria(this.bookSorter.selectedSortCriteria);
|
||||
}
|
||||
|
||||
|
||||
this.queryParamsService.syncQueryParams(
|
||||
this.currentViewMode!,
|
||||
this.bookSorter.selectedSort,
|
||||
this.bookSorter.selectedSortCriteria,
|
||||
this.selectedFilterMode.getValue(),
|
||||
this.parsedFilters
|
||||
);
|
||||
@@ -579,26 +582,37 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.seriesCollapseFilter.setCollapsed(value);
|
||||
}
|
||||
|
||||
onManualSortChange(sortOption: SortOption): void {
|
||||
this.applySortOption(sortOption);
|
||||
this.queryParamsService.updateSort(sortOption);
|
||||
onMultiSortChange(sortCriteria: SortOption[]): void {
|
||||
this.applySortCriteria(sortCriteria);
|
||||
this.queryParamsService.updateMultiSort(sortCriteria);
|
||||
}
|
||||
|
||||
applySortOption(sortOption: SortOption): void {
|
||||
// Backward compatibility wrapper
|
||||
onManualSortChange(sortOption: SortOption): void {
|
||||
this.onMultiSortChange([sortOption]);
|
||||
}
|
||||
|
||||
applySortCriteria(sortCriteria: SortOption[]): void {
|
||||
// Use first criterion for API call (backend doesn't support multi-sort)
|
||||
const primarySort = sortCriteria[0] ?? {field: 'addedOn', direction: 'DESCENDING', label: 'Added On'};
|
||||
|
||||
if (this.entityType === EntityType.ALL_BOOKS) {
|
||||
this.bookState$ = this.entityService.fetchAllBooks(sortOption).pipe(
|
||||
this.bookState$ = this.entityService.fetchAllBooks(primarySort).pipe(
|
||||
map(bookState => this.applyClientSideMultiSort(bookState, sortCriteria)),
|
||||
switchMap(bookState => this.applyBookFilters(bookState))
|
||||
);
|
||||
} else if (this.entityType === EntityType.UNSHELVED) {
|
||||
this.bookState$ = this.entityService.fetchUnshelvedBooks(sortOption).pipe(
|
||||
this.bookState$ = this.entityService.fetchUnshelvedBooks(primarySort).pipe(
|
||||
map(bookState => this.applyClientSideMultiSort(bookState, sortCriteria)),
|
||||
switchMap(bookState => this.applyBookFilters(bookState))
|
||||
);
|
||||
} else {
|
||||
const routeParam$ = this.entityService.getEntityInfoFromRoute(this.activatedRoute);
|
||||
this.bookState$ = routeParam$.pipe(
|
||||
switchMap(({entityId, entityType}) =>
|
||||
this.entityService.fetchBooksByEntity(entityId, entityType, sortOption)
|
||||
this.entityService.fetchBooksByEntity(entityId, entityType, primarySort)
|
||||
),
|
||||
map(bookState => this.applyClientSideMultiSort(bookState, sortCriteria)),
|
||||
switchMap(bookState => this.applyBookFilters(bookState))
|
||||
);
|
||||
}
|
||||
@@ -619,6 +633,49 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private applyClientSideMultiSort(bookState: BookState, sortCriteria: SortOption[]): BookState {
|
||||
if (!bookState.books || sortCriteria.length <= 1) {
|
||||
return bookState;
|
||||
}
|
||||
return {
|
||||
...bookState,
|
||||
books: this.sortService.applyMultiSort(bookState.books, sortCriteria)
|
||||
};
|
||||
}
|
||||
|
||||
// Backward compatibility wrapper
|
||||
applySortOption(sortOption: SortOption): void {
|
||||
this.applySortCriteria([sortOption]);
|
||||
}
|
||||
|
||||
private areSortCriteriaEqual(a: SortOption[], b: SortOption[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((criterion, index) =>
|
||||
criterion.field === b[index].field && criterion.direction === b[index].direction
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-sort popover handlers
|
||||
onAddSortCriterion(field: string): void {
|
||||
this.bookSorter.addSortCriterion(field);
|
||||
}
|
||||
|
||||
onRemoveSortCriterion(index: number): void {
|
||||
this.bookSorter.removeSortCriterion(index);
|
||||
}
|
||||
|
||||
onToggleSortDirection(index: number): void {
|
||||
this.bookSorter.toggleCriterionDirection(index);
|
||||
}
|
||||
|
||||
onReorderSortCriteria(event: CdkDragDrop<SortOption[]>): void {
|
||||
this.bookSorter.reorderCriteria(event);
|
||||
}
|
||||
|
||||
get sortCriteriaCount(): number {
|
||||
return this.bookSorter.selectedSortCriteria.length;
|
||||
}
|
||||
|
||||
onSearchTermChange(term: string): void {
|
||||
this.searchTerm$.next(term);
|
||||
}
|
||||
@@ -869,13 +926,20 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.activatedRoute.snapshot.queryParamMap
|
||||
);
|
||||
|
||||
// Use first sort criterion for series collapse filter
|
||||
const primarySort: SortOption = this.bookSorter.selectedSort ?? {
|
||||
field: 'addedOn',
|
||||
direction: SortDirection.DESCENDING,
|
||||
label: 'Added On'
|
||||
};
|
||||
|
||||
return this.filterOrchestrationService.applyFilters(
|
||||
bookState,
|
||||
this.headerFilter,
|
||||
this.sideBarFilter,
|
||||
this.seriesCollapseFilter,
|
||||
forceExpandSeries,
|
||||
this.bookSorter.selectedSort!
|
||||
primarySort
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {SortDirection, SortOption} from '../../../model/sort.model';
|
||||
import {CdkDragDrop, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
|
||||
export class BookSorter {
|
||||
selectedSort: SortOption | undefined = undefined;
|
||||
selectedSortCriteria: SortOption[] = [];
|
||||
|
||||
sortOptions: SortOption[] = [
|
||||
{label: 'Title', field: 'title', direction: SortDirection.ASCENDING},
|
||||
@@ -29,37 +30,114 @@ export class BookSorter {
|
||||
{label: 'Random', field: 'random', direction: SortDirection.ASCENDING},
|
||||
];
|
||||
|
||||
constructor(private applySortOption: (sort: SortOption) => void) {
|
||||
constructor(private onSortChange: (criteria: SortOption[]) => void) {
|
||||
}
|
||||
|
||||
// For backward compatibility - get first sort option
|
||||
get selectedSort(): SortOption | undefined {
|
||||
return this.selectedSortCriteria[0];
|
||||
}
|
||||
|
||||
// For backward compatibility - set from single sort option
|
||||
set selectedSort(sort: SortOption | undefined) {
|
||||
if (sort) {
|
||||
this.selectedSortCriteria = [sort];
|
||||
} else {
|
||||
this.selectedSortCriteria = [];
|
||||
}
|
||||
}
|
||||
|
||||
setSortCriteria(criteria: SortOption[]): void {
|
||||
this.selectedSortCriteria = [...criteria];
|
||||
this.updateSortOptions();
|
||||
}
|
||||
|
||||
// Quick sort by field - toggles direction if already selected as primary sort
|
||||
sortBooks(field: string): void {
|
||||
const existingSort = this.sortOptions.find(opt => opt.field === field);
|
||||
if (!existingSort) return;
|
||||
|
||||
if (this.selectedSort?.field === field) {
|
||||
this.selectedSort = {
|
||||
...this.selectedSort,
|
||||
direction: this.selectedSort.direction === SortDirection.ASCENDING
|
||||
// If this field is the first (primary) sort, toggle its direction
|
||||
if (this.selectedSortCriteria.length > 0 && this.selectedSortCriteria[0].field === field) {
|
||||
this.selectedSortCriteria[0] = {
|
||||
...this.selectedSortCriteria[0],
|
||||
direction: this.selectedSortCriteria[0].direction === SortDirection.ASCENDING
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING
|
||||
};
|
||||
} else {
|
||||
this.selectedSort = {
|
||||
// Set as the only sort criterion (single click behavior)
|
||||
this.selectedSortCriteria = [{
|
||||
label: existingSort.label,
|
||||
field: existingSort.field,
|
||||
direction: SortDirection.ASCENDING
|
||||
};
|
||||
}];
|
||||
}
|
||||
|
||||
this.updateSortOptions();
|
||||
this.applySortOption(this.selectedSort);
|
||||
this.onSortChange(this.selectedSortCriteria);
|
||||
}
|
||||
|
||||
updateSortOptions() {
|
||||
const directionIcon = this.selectedSort!.direction === SortDirection.ASCENDING ? 'pi pi-arrow-up' : 'pi pi-arrow-down';
|
||||
addSortCriterion(field: string): void {
|
||||
// Don't add if already exists
|
||||
if (this.selectedSortCriteria.some(c => c.field === field)) return;
|
||||
|
||||
const option = this.sortOptions.find(opt => opt.field === field);
|
||||
if (!option) return;
|
||||
|
||||
this.selectedSortCriteria.push({
|
||||
label: option.label,
|
||||
field: option.field,
|
||||
direction: SortDirection.ASCENDING
|
||||
});
|
||||
|
||||
this.updateSortOptions();
|
||||
this.onSortChange(this.selectedSortCriteria);
|
||||
}
|
||||
|
||||
removeSortCriterion(index: number): void {
|
||||
if (index < 0 || index >= this.selectedSortCriteria.length) return;
|
||||
|
||||
this.selectedSortCriteria.splice(index, 1);
|
||||
this.updateSortOptions();
|
||||
this.onSortChange(this.selectedSortCriteria);
|
||||
}
|
||||
|
||||
toggleCriterionDirection(index: number): void {
|
||||
if (index < 0 || index >= this.selectedSortCriteria.length) return;
|
||||
|
||||
const criterion = this.selectedSortCriteria[index];
|
||||
this.selectedSortCriteria[index] = {
|
||||
...criterion,
|
||||
direction: criterion.direction === SortDirection.ASCENDING
|
||||
? SortDirection.DESCENDING
|
||||
: SortDirection.ASCENDING
|
||||
};
|
||||
|
||||
this.updateSortOptions();
|
||||
this.onSortChange(this.selectedSortCriteria);
|
||||
}
|
||||
|
||||
reorderCriteria(event: CdkDragDrop<SortOption[]>): void {
|
||||
moveItemInArray(this.selectedSortCriteria, event.previousIndex, event.currentIndex);
|
||||
this.updateSortOptions();
|
||||
this.onSortChange(this.selectedSortCriteria);
|
||||
}
|
||||
|
||||
getAvailableSortOptions(): SortOption[] {
|
||||
const usedFields = new Set(this.selectedSortCriteria.map(c => c.field));
|
||||
return this.sortOptions.filter(opt => !usedFields.has(opt.field));
|
||||
}
|
||||
|
||||
updateSortOptions(): void {
|
||||
const primaryField = this.selectedSortCriteria[0]?.field;
|
||||
const primaryDirection = this.selectedSortCriteria[0]?.direction;
|
||||
|
||||
const directionIcon = primaryDirection === SortDirection.ASCENDING ? 'pi pi-arrow-up' : 'pi pi-arrow-down';
|
||||
|
||||
this.sortOptions = this.sortOptions.map((option) => ({
|
||||
...option,
|
||||
icon: option.field === this.selectedSort!.field ? directionIcon : '',
|
||||
icon: option.field === primaryField ? directionIcon : '',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<div class="multi-sort-container">
|
||||
<div class="multi-sort-header">
|
||||
<span class="header-title">Sort Order</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="sort-criteria-list"
|
||||
cdkDropList
|
||||
[cdkDropListData]="sortCriteria"
|
||||
(cdkDropListDropped)="onDrop($event)">
|
||||
@for (criterion of sortCriteria; track criterion.field; let i = $index) {
|
||||
<div class="sort-criterion" cdkDrag>
|
||||
<div class="criterion-drag-handle" cdkDragHandle>
|
||||
<i class="pi pi-bars"></i>
|
||||
</div>
|
||||
<span class="criterion-rank">{{ i + 1 }}.</span>
|
||||
<span class="criterion-label">{{ criterion.label }}</span>
|
||||
<div class="criterion-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="direction-btn"
|
||||
(click)="onToggleDirection(i)"
|
||||
[pTooltip]="getDirectionTooltip(criterion.direction)"
|
||||
tooltipPosition="top">
|
||||
<i [class]="getDirectionIcon(criterion.direction)"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-btn"
|
||||
(click)="onRemove(i)"
|
||||
pTooltip="Remove"
|
||||
tooltipPosition="top"
|
||||
[disabled]="sortCriteria.length === 1">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (unusedOptions.length > 0) {
|
||||
<div class="add-sort-section">
|
||||
<p-select
|
||||
[options]="unusedOptions"
|
||||
[(ngModel)]="selectedField"
|
||||
optionLabel="label"
|
||||
optionValue="field"
|
||||
placeholder="Add sort field..."
|
||||
[style]="{ width: '100%' }"
|
||||
size="small"
|
||||
appendTo="body"
|
||||
(onChange)="onAddField()">
|
||||
</p-select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,144 @@
|
||||
.multi-sort-container {
|
||||
width: 280px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.multi-sort-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sort-criteria-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.sort-criterion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--overlay-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: move;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ground-background);
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--card-background);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sort-criteria-list.cdk-drop-list-dragging .sort-criterion:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.criterion-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary-color);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.criterion-rank {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
|
||||
.criterion-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.criterion-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.direction-btn,
|
||||
.remove-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--ground-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.direction-btn {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.add-sort-section {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {SortDirection, SortOption} from '../../../../model/sort.model';
|
||||
import {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList} from '@angular/cdk/drag-drop';
|
||||
import {Select} from 'primeng/select';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
|
||||
@Component({
|
||||
selector: 'app-multi-sort-popover',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
Select,
|
||||
FormsModule,
|
||||
Tooltip
|
||||
],
|
||||
templateUrl: './multi-sort-popover.component.html',
|
||||
styleUrl: './multi-sort-popover.component.scss'
|
||||
})
|
||||
export class MultiSortPopoverComponent {
|
||||
@Input() sortCriteria: SortOption[] = [];
|
||||
@Input() availableSortOptions: SortOption[] = [];
|
||||
|
||||
@Output() criteriaChange = new EventEmitter<SortOption[]>();
|
||||
@Output() addCriterion = new EventEmitter<string>();
|
||||
@Output() removeCriterion = new EventEmitter<number>();
|
||||
@Output() toggleDirection = new EventEmitter<number>();
|
||||
@Output() reorder = new EventEmitter<CdkDragDrop<SortOption[]>>();
|
||||
|
||||
selectedField: string | null = null;
|
||||
|
||||
get unusedOptions(): SortOption[] {
|
||||
const usedFields = new Set(this.sortCriteria.map(c => c.field));
|
||||
return this.availableSortOptions.filter(opt => !usedFields.has(opt.field));
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<SortOption[]>): void {
|
||||
this.reorder.emit(event);
|
||||
}
|
||||
|
||||
onToggleDirection(index: number): void {
|
||||
this.toggleDirection.emit(index);
|
||||
}
|
||||
|
||||
onRemove(index: number): void {
|
||||
this.removeCriterion.emit(index);
|
||||
}
|
||||
|
||||
onAddField(): void {
|
||||
if (this.selectedField) {
|
||||
this.addCriterion.emit(this.selectedField);
|
||||
this.selectedField = null;
|
||||
}
|
||||
}
|
||||
|
||||
getDirectionIcon(direction: SortDirection): string {
|
||||
return direction === SortDirection.ASCENDING ? 'pi pi-arrow-up' : 'pi pi-arrow-down';
|
||||
}
|
||||
|
||||
getDirectionTooltip(direction: SortDirection): string {
|
||||
return direction === SortDirection.ASCENDING ? 'Ascending - click to change' : 'Descending - click to change';
|
||||
}
|
||||
|
||||
protected readonly SortDirection = SortDirection;
|
||||
}
|
||||
@@ -109,56 +109,67 @@ export class SortService {
|
||||
|
||||
applySort(books: Book[], selectedSort: SortOption | null): Book[] {
|
||||
if (!selectedSort) return books;
|
||||
return this.applyMultiSort(books, [selectedSort]);
|
||||
}
|
||||
|
||||
const {field, direction} = selectedSort;
|
||||
const extractor = this.fieldExtractors[field];
|
||||
|
||||
if (!extractor) {
|
||||
console.warn(`[SortService] No extractor for field: ${field}`);
|
||||
return books;
|
||||
}
|
||||
applyMultiSort(books: Book[], sortCriteria: SortOption[]): Book[] {
|
||||
if (!sortCriteria || sortCriteria.length === 0) return books;
|
||||
|
||||
return books.slice().sort((a, b) => {
|
||||
const aValue = extractor(a);
|
||||
const bValue = extractor(b);
|
||||
|
||||
let result = 0;
|
||||
|
||||
if (Array.isArray(aValue) && Array.isArray(bValue)) {
|
||||
for (let i = 0; i < aValue.length; i++) {
|
||||
const valA = aValue[i];
|
||||
const valB = bValue[i];
|
||||
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
result = this.naturalCompare(valA, valB);
|
||||
if (result !== 0) break;
|
||||
} else if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
result = valA - valB;
|
||||
if (result !== 0) break;
|
||||
} else {
|
||||
if (valA == null && valB != null) {
|
||||
result = 1;
|
||||
break;
|
||||
}
|
||||
if (valA != null && valB == null) {
|
||||
result = -1;
|
||||
break;
|
||||
}
|
||||
result = 0;
|
||||
}
|
||||
}
|
||||
} else if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
result = this.naturalCompare(aValue, bValue);
|
||||
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
result = aValue - bValue;
|
||||
} else {
|
||||
// Handle nulls or mismatches
|
||||
if (aValue == null && bValue != null) return 1;
|
||||
if (aValue != null && bValue == null) return -1;
|
||||
return 0;
|
||||
for (const criterion of sortCriteria) {
|
||||
const result = this.compareByCriterion(a, b, criterion);
|
||||
if (result !== 0) return result;
|
||||
}
|
||||
|
||||
return direction === SortDirection.ASCENDING ? result : -result;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
private compareByCriterion(a: Book, b: Book, criterion: SortOption): number {
|
||||
const extractor = this.fieldExtractors[criterion.field];
|
||||
|
||||
if (!extractor) {
|
||||
console.warn(`[SortService] No extractor for field: ${criterion.field}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const aValue = extractor(a);
|
||||
const bValue = extractor(b);
|
||||
|
||||
let result = this.compareValues(aValue, bValue);
|
||||
|
||||
return criterion.direction === SortDirection.ASCENDING ? result : -result;
|
||||
}
|
||||
|
||||
private compareValues(aValue: unknown, bValue: unknown): number {
|
||||
if (Array.isArray(aValue) && Array.isArray(bValue)) {
|
||||
return this.compareArrays(aValue, bValue);
|
||||
} else if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return this.naturalCompare(aValue, bValue);
|
||||
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return aValue - bValue;
|
||||
} else {
|
||||
if (aValue == null && bValue != null) return 1;
|
||||
if (aValue != null && bValue == null) return -1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private compareArrays(aValue: unknown[], bValue: unknown[]): number {
|
||||
for (let i = 0; i < aValue.length; i++) {
|
||||
const valA = aValue[i];
|
||||
const valB = bValue[i];
|
||||
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
const result = this.naturalCompare(valA, valB);
|
||||
if (result !== 0) return result;
|
||||
} else if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
const result = valA - valB;
|
||||
if (result !== 0) return result;
|
||||
} else {
|
||||
if (valA == null && valB != null) return 1;
|
||||
if (valA != null && valB == null) return -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,15 @@ export interface EntityViewPreferences {
|
||||
overrides: EntityViewPreferenceOverride[];
|
||||
}
|
||||
|
||||
export interface SortCriterion {
|
||||
field: string;
|
||||
direction: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface EntityViewPreference {
|
||||
sortKey: string;
|
||||
sortDir: 'ASC' | 'DESC';
|
||||
sortCriteria?: SortCriterion[];
|
||||
view: 'GRID' | 'TABLE';
|
||||
coverSize: number;
|
||||
seriesCollapsed: boolean;
|
||||
|
||||
@@ -14,17 +14,64 @@
|
||||
<div class="setting-header-standalone">
|
||||
<label class="setting-label">Default Preferences</label>
|
||||
</div>
|
||||
<p class="setting-description">Configure the default sorting field, direction, and view mode for all libraries and shelves.</p>
|
||||
<p class="setting-description">Configure the default view mode and multi-field sorting for all libraries and shelves. Drag to reorder sort priority.</p>
|
||||
<div class="setting-options">
|
||||
<div class="select-group">
|
||||
<p-select size="small" [options]="sortOptions" optionLabel="label" optionValue="field"
|
||||
[(ngModel)]="selectedSort" placeholder="Sort By" appendTo="body"></p-select>
|
||||
<p-select size="small" [options]="sortDirectionOptions" [(ngModel)]="selectedSortDir"
|
||||
placeholder="Direction" appendTo="body"></p-select>
|
||||
<p-select size="small" [options]="viewModeOptions" [(ngModel)]="selectedView"
|
||||
placeholder="View Mode" appendTo="body"></p-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="multi-sort-editor">
|
||||
<label class="multi-sort-label">Sort Order (drag to reorder)</label>
|
||||
<div
|
||||
class="sort-criteria-list"
|
||||
cdkDropList
|
||||
[cdkDropListData]="sortCriteria"
|
||||
(cdkDropListDropped)="onSortCriteriaDrop($event)">
|
||||
@for (criterion of sortCriteria; track criterion.field; let i = $index) {
|
||||
<div class="sort-criterion-row" cdkDrag>
|
||||
<div class="criterion-drag-handle" cdkDragHandle>
|
||||
<i class="pi pi-bars"></i>
|
||||
</div>
|
||||
<span class="criterion-rank">{{ i + 1 }}.</span>
|
||||
<span class="criterion-label">{{ getSortLabel(criterion.field) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="direction-toggle-btn"
|
||||
(click)="toggleSortDirection(i)"
|
||||
[pTooltip]="criterion.direction === 'ASC' ? 'Ascending' : 'Descending'"
|
||||
tooltipPosition="top">
|
||||
<i [class]="criterion.direction === 'ASC' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-criterion-btn"
|
||||
(click)="removeSortCriterion(i)"
|
||||
[disabled]="sortCriteria.length === 1"
|
||||
pTooltip="Remove"
|
||||
tooltipPosition="top">
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (availableSortFields.length > 0) {
|
||||
<div class="add-sort-row">
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="availableSortFields"
|
||||
[(ngModel)]="selectedAddField"
|
||||
optionLabel="label"
|
||||
optionValue="field"
|
||||
placeholder="Add sort field..."
|
||||
[style]="{ width: '200px' }"
|
||||
appendTo="body"
|
||||
(onChange)="addSortCriterion()">
|
||||
</p-select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
|
||||
@@ -56,5 +56,136 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--p-content-border-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
// Multi-sort editor styles
|
||||
.multi-sort-editor {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.multi-sort-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sort-criteria-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 40px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sort-criterion-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--overlay-background);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: move;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ground-background);
|
||||
}
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: var(--card-background);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sort-criteria-list.cdk-drop-list-dragging .sort-criterion-row:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.criterion-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary-color);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.criterion-rank {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
|
||||
.criterion-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.direction-toggle-btn,
|
||||
.remove-criterion-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--ground-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.direction-toggle-btn {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.remove-criterion-btn {
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.add-sort-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Button} from 'primeng/button';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Select} from 'primeng/select';
|
||||
import {TableModule} from 'primeng/table';
|
||||
import {User, UserService} from '../../user-management/user.service';
|
||||
import {SortCriterion, User, UserService} from '../../user-management/user.service';
|
||||
import {LibraryService} from '../../../book/service/library.service';
|
||||
import {ShelfService} from '../../../book/service/shelf.service';
|
||||
import {MagicShelfService} from '../../../magic-shelf/service/magic-shelf.service';
|
||||
@@ -14,6 +14,7 @@ import {ToastModule} from 'primeng/toast';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {filter, take, takeUntil} from 'rxjs/operators';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
import {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-preferences',
|
||||
@@ -25,7 +26,10 @@ import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
TableModule,
|
||||
ToastModule,
|
||||
Tooltip,
|
||||
ToggleSwitch
|
||||
ToggleSwitch,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle
|
||||
],
|
||||
templateUrl: './view-preferences.component.html',
|
||||
styleUrl: './view-preferences.component.scss'
|
||||
@@ -81,6 +85,8 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
selectedSortDir: 'ASC' | 'DESC' = 'ASC';
|
||||
selectedView: 'GRID' | 'TABLE' = 'GRID';
|
||||
autoSaveMetadata: boolean = false;
|
||||
sortCriteria: SortCriterion[] = [];
|
||||
selectedAddField: string | null = null;
|
||||
|
||||
overrides: {
|
||||
entityType: 'LIBRARY' | 'SHELF' | 'MAGIC_SHELF';
|
||||
@@ -117,6 +123,13 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.selectedView = global?.view ?? 'GRID';
|
||||
this.autoSaveMetadata = userState.user?.userSettings?.autoSaveMetadata ?? false;
|
||||
|
||||
// Load multi-sort criteria, falling back to legacy single sort
|
||||
if (global?.sortCriteria && global.sortCriteria.length > 0) {
|
||||
this.sortCriteria = [...global.sortCriteria];
|
||||
} else {
|
||||
this.sortCriteria = [{field: this.selectedSort, direction: this.selectedSortDir}];
|
||||
}
|
||||
|
||||
this.overrides = (prefs?.overrides ?? []).map(o => ({
|
||||
entityType: o.entityType,
|
||||
library: o.entityId,
|
||||
@@ -195,6 +208,50 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.overrides.splice(index, 1);
|
||||
}
|
||||
|
||||
// Multi-sort criteria methods
|
||||
get availableSortFields(): {label: string; field: string}[] {
|
||||
const usedFields = new Set(this.sortCriteria.map(c => c.field));
|
||||
return this.sortOptions.filter(opt => !usedFields.has(opt.field));
|
||||
}
|
||||
|
||||
getSortLabel(field: string): string {
|
||||
return this.sortOptions.find(opt => opt.field === field)?.label ?? field;
|
||||
}
|
||||
|
||||
addSortCriterion(): void {
|
||||
if (this.selectedAddField) {
|
||||
this.sortCriteria.push({field: this.selectedAddField, direction: 'ASC'});
|
||||
this.selectedAddField = null;
|
||||
this.syncLegacySort();
|
||||
}
|
||||
}
|
||||
|
||||
removeSortCriterion(index: number): void {
|
||||
if (this.sortCriteria.length > 1) {
|
||||
this.sortCriteria.splice(index, 1);
|
||||
this.syncLegacySort();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSortDirection(index: number): void {
|
||||
const criterion = this.sortCriteria[index];
|
||||
criterion.direction = criterion.direction === 'ASC' ? 'DESC' : 'ASC';
|
||||
this.syncLegacySort();
|
||||
}
|
||||
|
||||
onSortCriteriaDrop(event: CdkDragDrop<SortCriterion[]>): void {
|
||||
moveItemInArray(this.sortCriteria, event.previousIndex, event.currentIndex);
|
||||
this.syncLegacySort();
|
||||
}
|
||||
|
||||
private syncLegacySort(): void {
|
||||
// Keep legacy fields in sync with first criterion
|
||||
if (this.sortCriteria.length > 0) {
|
||||
this.selectedSort = this.sortCriteria[0].field;
|
||||
this.selectedSortDir = this.sortCriteria[0].direction;
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
if (!this.user) return;
|
||||
|
||||
@@ -204,6 +261,7 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
...prefs.global,
|
||||
sortKey: this.selectedSort,
|
||||
sortDir: this.selectedSortDir,
|
||||
sortCriteria: [...this.sortCriteria],
|
||||
view: this.selectedView
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user