feat(sorting): add multi-field sorting support (#2628)

This commit is contained in:
ACX
2026-02-05 22:22:50 -07:00
committed by GitHub
parent f34b18f2b8
commit e0c3d8b50d
13 changed files with 873 additions and 130 deletions

View File

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

View File

@@ -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()">

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

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