mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(sort): replace compound sort options with atomic fields and multi-sort support (#2686)
* fix(sidebar): replace routerLinkActive with getter-based route matching to prevent stale highlights * feat(sort): replace compound sort options with atomic fields and add 6 new sort criteria
This commit is contained in:
@@ -61,6 +61,8 @@ public class BookLoreUserTransformer {
|
||||
case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
|
||||
case VISIBLE_FILTERS -> userSettings.setVisibleFilters(objectMapper.readValue(value, new TypeReference<>() {
|
||||
}));
|
||||
case VISIBLE_SORT_FIELDS -> userSettings.setVisibleSortFields(objectMapper.readValue(value, new TypeReference<>() {
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
switch (settingKey) {
|
||||
|
||||
@@ -78,6 +78,7 @@ public class BookLoreUser {
|
||||
public boolean enableSeriesView;
|
||||
public boolean autoSaveMetadata;
|
||||
public List<String> visibleFilters;
|
||||
public List<String> visibleSortFields;
|
||||
public DashboardConfig dashboardConfig;
|
||||
|
||||
@Data
|
||||
@@ -107,6 +108,7 @@ public class BookLoreUser {
|
||||
public static class GlobalPreferences {
|
||||
private String sortKey;
|
||||
private String sortDir;
|
||||
private List<SortCriterion> sortCriteria;
|
||||
private String view;
|
||||
private Float coverSize;
|
||||
@JsonAlias("seriesCollapse")
|
||||
@@ -131,10 +133,21 @@ public class BookLoreUser {
|
||||
public static class OverrideDetails {
|
||||
private String sortKey;
|
||||
private String sortDir;
|
||||
private List<SortCriterion> sortCriteria;
|
||||
private String view;
|
||||
@JsonAlias("seriesCollapse")
|
||||
private Boolean seriesCollapsed;
|
||||
private Boolean overlayBookType;
|
||||
private Float coverSize;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public static class SortCriterion {
|
||||
private String field;
|
||||
private String direction;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -23,7 +23,8 @@ public enum UserSettingKey {
|
||||
HARDCOVER_API_KEY("hardcoverApiKey", false),
|
||||
HARDCOVER_SYNC_ENABLED("hardcoverSyncEnabled", false),
|
||||
AUTO_SAVE_METADATA("autoSaveMetadata", false),
|
||||
VISIBLE_FILTERS("visibleFilters", true);
|
||||
VISIBLE_FILTERS("visibleFilters", true),
|
||||
VISIBLE_SORT_FIELDS("visibleSortFields", true);
|
||||
|
||||
|
||||
private final String dbKey;
|
||||
|
||||
@@ -177,11 +177,10 @@
|
||||
<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)">
|
||||
[availableSortOptions]="visibleSortOptions"
|
||||
[showSaveButton]="canSaveSort"
|
||||
(criteriaChange)="onSortCriteriaChange($event)"
|
||||
(saveSortConfig)="onSaveSortConfig($event)">
|
||||
</app-multi-sort-popover>
|
||||
</p-popover>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {InputText} from 'primeng/inputtext';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {BookFilterComponent} from './book-filter/book-filter.component';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {BookFilterMode, EntityViewPreferences, UserService} from '../../../settings/user-management/user.service';
|
||||
import {BookFilterMode, DEFAULT_VISIBLE_SORT_FIELDS, EntityViewPreferences, SortCriterion, UserService} from '../../../settings/user-management/user.service';
|
||||
import {SeriesCollapseFilter} from './filters/SeriesCollapseFilter';
|
||||
import {SideBarFilter} from './filters/sidebar-filter';
|
||||
import {HeaderFilter} from './filters/HeaderFilter';
|
||||
@@ -56,7 +56,6 @@ import {BookFilterOrchestrationService} from './book-filter-orchestration.servic
|
||||
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 {
|
||||
@@ -144,6 +143,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
entityViewPreferences: EntityViewPreferences | undefined;
|
||||
currentViewMode: string | undefined;
|
||||
lastAppliedSortCriteria: SortOption[] = [];
|
||||
visibleSortOptions: SortOption[] = [];
|
||||
showFilter = false;
|
||||
screenWidth = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
||||
mobileColumnCount = 3;
|
||||
@@ -446,8 +446,15 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.columnPreferenceService.initPreferences(user.user?.userSettings?.tableColumnPreference);
|
||||
this.visibleColumns = this.columnPreferenceService.visibleColumns;
|
||||
|
||||
const visibleFields = user.user?.userSettings?.visibleSortFields ?? DEFAULT_VISIBLE_SORT_FIELDS;
|
||||
const sortOptionsByField = new Map(this.bookSorter.sortOptions.map(o => [o.field, o]));
|
||||
this.visibleSortOptions = visibleFields.map(f => sortOptionsByField.get(f)).filter((o): o is SortOption => !!o);
|
||||
|
||||
this.bookSorter.setSortCriteria(parseResult.sortCriteria);
|
||||
|
||||
// Only update sort criteria if they actually changed to avoid resetting popover/CDK state
|
||||
if (!this.areSortCriteriaEqual(this.bookSorter.selectedSortCriteria, parseResult.sortCriteria)) {
|
||||
this.bookSorter.setSortCriteria(parseResult.sortCriteria);
|
||||
}
|
||||
this.currentViewMode = parseResult.viewMode;
|
||||
|
||||
if (!this.areSortCriteriaEqual(this.lastAppliedSortCriteria, this.bookSorter.selectedSortCriteria)) {
|
||||
@@ -650,21 +657,89 @@ export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// Multi-sort popover handlers
|
||||
onAddSortCriterion(field: string): void {
|
||||
this.bookSorter.addSortCriterion(field);
|
||||
onSortCriteriaChange(criteria: SortOption[]): void {
|
||||
this.bookSorter.setSortCriteria(criteria);
|
||||
this.onMultiSortChange(criteria);
|
||||
}
|
||||
|
||||
onRemoveSortCriterion(index: number): void {
|
||||
this.bookSorter.removeSortCriterion(index);
|
||||
get canSaveSort(): boolean {
|
||||
return this.entityType === EntityType.LIBRARY ||
|
||||
this.entityType === EntityType.SHELF ||
|
||||
this.entityType === EntityType.MAGIC_SHELF ||
|
||||
this.entityType === EntityType.ALL_BOOKS ||
|
||||
this.entityType === EntityType.UNSHELVED;
|
||||
}
|
||||
|
||||
onToggleSortDirection(index: number): void {
|
||||
this.bookSorter.toggleCriterionDirection(index);
|
||||
}
|
||||
onSaveSortConfig(criteria: SortOption[]): void {
|
||||
if (!this.entityType) return;
|
||||
|
||||
onReorderSortCriteria(event: CdkDragDrop<SortOption[]>): void {
|
||||
this.bookSorter.reorderCriteria(event);
|
||||
const user = this.userService.getCurrentUser();
|
||||
if (!user) return;
|
||||
|
||||
const sortCriteria: SortCriterion[] = criteria.map(c => ({
|
||||
field: c.field,
|
||||
direction: c.direction === SortDirection.ASCENDING ? 'ASC' as const : 'DESC' as const
|
||||
}));
|
||||
|
||||
const prefs: EntityViewPreferences = structuredClone(
|
||||
user.userSettings.entityViewPreferences ?? {global: {sortKey: 'title', sortDir: 'ASC', view: 'GRID', coverSize: 1.0, seriesCollapsed: false, overlayBookType: true}, overrides: []}
|
||||
);
|
||||
|
||||
if (this.entityType === EntityType.ALL_BOOKS || this.entityType === EntityType.UNSHELVED) {
|
||||
prefs.global = {
|
||||
...prefs.global,
|
||||
sortKey: sortCriteria[0]?.field ?? 'title',
|
||||
sortDir: sortCriteria[0]?.direction ?? 'ASC',
|
||||
sortCriteria
|
||||
};
|
||||
} else {
|
||||
if (!this.entity) return;
|
||||
if (!prefs.overrides) prefs.overrides = [];
|
||||
|
||||
let overrideEntityType: 'LIBRARY' | 'SHELF' | 'MAGIC_SHELF';
|
||||
switch (this.entityType) {
|
||||
case EntityType.LIBRARY: overrideEntityType = 'LIBRARY'; break;
|
||||
case EntityType.SHELF: overrideEntityType = 'SHELF'; break;
|
||||
case EntityType.MAGIC_SHELF: overrideEntityType = 'MAGIC_SHELF'; break;
|
||||
default: return;
|
||||
}
|
||||
|
||||
const existingIndex = prefs.overrides.findIndex(
|
||||
o => o.entityType === overrideEntityType && o.entityId === this.entity!.id
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
prefs.overrides[existingIndex].preferences = {
|
||||
...prefs.overrides[existingIndex].preferences,
|
||||
sortKey: sortCriteria[0]?.field ?? 'title',
|
||||
sortDir: sortCriteria[0]?.direction ?? 'ASC',
|
||||
sortCriteria
|
||||
};
|
||||
} else {
|
||||
prefs.overrides.push({
|
||||
entityType: overrideEntityType,
|
||||
entityId: this.entity!.id!,
|
||||
preferences: {
|
||||
sortKey: sortCriteria[0]?.field ?? 'title',
|
||||
sortDir: sortCriteria[0]?.direction ?? 'ASC',
|
||||
sortCriteria,
|
||||
view: 'GRID',
|
||||
coverSize: 1.0,
|
||||
seriesCollapsed: false,
|
||||
overlayBookType: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.userService.updateUserSetting(user.id, 'entityViewPreferences', prefs);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Sort Saved',
|
||||
detail: this.entityType === EntityType.ALL_BOOKS || this.entityType === EntityType.UNSHELVED
|
||||
? 'Default sort configuration saved.'
|
||||
: `Sort configuration saved for this ${this.entityType.toLowerCase()}.`
|
||||
});
|
||||
}
|
||||
|
||||
get sortCriteriaCount(): number {
|
||||
|
||||
@@ -6,12 +6,12 @@ export class BookSorter {
|
||||
|
||||
sortOptions: SortOption[] = [
|
||||
{label: 'Title', field: 'title', direction: SortDirection.ASCENDING},
|
||||
{label: 'Title + Series', field: 'titleSeries', direction: SortDirection.ASCENDING},
|
||||
{label: 'File Name', field: 'fileName', direction: SortDirection.ASCENDING},
|
||||
{label: 'File Path', field: 'filePath', direction: SortDirection.ASCENDING},
|
||||
{label: 'Author', field: 'author', direction: SortDirection.ASCENDING},
|
||||
{label: 'Author (Surname)', field: 'authorSurnameVorname', direction: SortDirection.ASCENDING},
|
||||
{label: 'Author + Series', field: 'authorSeries', direction: SortDirection.ASCENDING},
|
||||
{label: 'Series Name', field: 'seriesName', direction: SortDirection.ASCENDING},
|
||||
{label: 'Series Number', field: 'seriesNumber', direction: SortDirection.ASCENDING},
|
||||
{label: 'Last Read', field: 'lastReadTime', direction: SortDirection.ASCENDING},
|
||||
{label: 'Personal Rating', field: 'personalRating', direction: SortDirection.ASCENDING},
|
||||
{label: 'Added On', field: 'addedOn', direction: SortDirection.ASCENDING},
|
||||
@@ -19,6 +19,10 @@ export class BookSorter {
|
||||
{label: 'Locked', field: 'locked', direction: SortDirection.ASCENDING},
|
||||
{label: 'Publisher', field: 'publisher', direction: SortDirection.ASCENDING},
|
||||
{label: 'Published Date', field: 'publishedDate', direction: SortDirection.ASCENDING},
|
||||
{label: 'Read Status', field: 'readStatus', direction: SortDirection.ASCENDING},
|
||||
{label: 'Date Finished', field: 'dateFinished', direction: SortDirection.ASCENDING},
|
||||
{label: 'Reading Progress', field: 'readingProgress', direction: SortDirection.ASCENDING},
|
||||
{label: 'Book Type', field: 'bookType', direction: SortDirection.ASCENDING},
|
||||
{label: 'Amazon Rating', field: 'amazonRating', direction: SortDirection.ASCENDING},
|
||||
{label: 'Amazon #', field: 'amazonReviewCount', direction: SortDirection.ASCENDING},
|
||||
{label: 'Goodreads Rating', field: 'goodreadsRating', direction: SortDirection.ASCENDING},
|
||||
|
||||
@@ -53,4 +53,17 @@
|
||||
</p-select>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showSaveButton) {
|
||||
<div class="save-sort-section">
|
||||
<p-button
|
||||
icon="pi pi-save"
|
||||
label="Save as Default"
|
||||
size="small"
|
||||
[outlined]="true"
|
||||
[style]="{ width: '100%' }"
|
||||
(onClick)="onSave()">
|
||||
</p-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -142,3 +142,9 @@
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.save-sort-section {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
import {Select} from 'primeng/select';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {Button} from 'primeng/button';
|
||||
|
||||
@Component({
|
||||
selector: 'app-multi-sort-popover',
|
||||
@@ -14,7 +15,8 @@ import {Tooltip} from 'primeng/tooltip';
|
||||
CdkDragHandle,
|
||||
Select,
|
||||
FormsModule,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Button
|
||||
],
|
||||
templateUrl: './multi-sort-popover.component.html',
|
||||
styleUrl: './multi-sort-popover.component.scss'
|
||||
@@ -22,12 +24,10 @@ import {Tooltip} from 'primeng/tooltip';
|
||||
export class MultiSortPopoverComponent {
|
||||
@Input() sortCriteria: SortOption[] = [];
|
||||
@Input() availableSortOptions: SortOption[] = [];
|
||||
@Input() showSaveButton = false;
|
||||
|
||||
@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[]>>();
|
||||
@Output() saveSortConfig = new EventEmitter<SortOption[]>();
|
||||
|
||||
selectedField: string | null = null;
|
||||
|
||||
@@ -37,22 +37,41 @@ export class MultiSortPopoverComponent {
|
||||
}
|
||||
|
||||
onDrop(event: CdkDragDrop<SortOption[]>): void {
|
||||
this.reorder.emit(event);
|
||||
const criteria = [...this.sortCriteria];
|
||||
moveItemInArray(criteria, event.previousIndex, event.currentIndex);
|
||||
this.sortCriteria = criteria;
|
||||
this.criteriaChange.emit(this.sortCriteria);
|
||||
}
|
||||
|
||||
onToggleDirection(index: number): void {
|
||||
this.toggleDirection.emit(index);
|
||||
this.sortCriteria = this.sortCriteria.map((c, i) =>
|
||||
i === index
|
||||
? {...c, direction: c.direction === SortDirection.ASCENDING ? SortDirection.DESCENDING : SortDirection.ASCENDING}
|
||||
: c
|
||||
);
|
||||
this.criteriaChange.emit(this.sortCriteria);
|
||||
}
|
||||
|
||||
onRemove(index: number): void {
|
||||
this.removeCriterion.emit(index);
|
||||
this.sortCriteria = this.sortCriteria.filter((_, i) => i !== index);
|
||||
this.criteriaChange.emit(this.sortCriteria);
|
||||
}
|
||||
|
||||
onAddField(): void {
|
||||
if (this.selectedField) {
|
||||
this.addCriterion.emit(this.selectedField);
|
||||
this.selectedField = null;
|
||||
}
|
||||
if (!this.selectedField) return;
|
||||
const option = this.availableSortOptions.find(o => o.field === this.selectedField);
|
||||
if (!option) return;
|
||||
this.sortCriteria = [...this.sortCriteria, {
|
||||
label: option.label,
|
||||
field: option.field,
|
||||
direction: SortDirection.ASCENDING
|
||||
}];
|
||||
this.selectedField = null;
|
||||
this.criteriaChange.emit(this.sortCriteria);
|
||||
}
|
||||
|
||||
onSave(): void {
|
||||
this.saveSortConfig.emit(this.sortCriteria);
|
||||
}
|
||||
|
||||
getDirectionIcon(direction: SortDirection): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Book} from '../model/book.model';
|
||||
import {Book, ReadStatus} from '../model/book.model';
|
||||
import {SortDirection, SortOption} from "../model/sort.model";
|
||||
|
||||
@Injectable({
|
||||
@@ -48,18 +48,22 @@ export class SortService {
|
||||
return aChunks.length - bChunks.length;
|
||||
}
|
||||
|
||||
private static readonly READ_STATUS_RANK: Record<string, number> = {
|
||||
[ReadStatus.UNSET]: 0,
|
||||
[ReadStatus.UNREAD]: 1,
|
||||
[ReadStatus.READING]: 2,
|
||||
[ReadStatus.RE_READING]: 3,
|
||||
[ReadStatus.PARTIALLY_READ]: 4,
|
||||
[ReadStatus.PAUSED]: 5,
|
||||
[ReadStatus.READ]: 6,
|
||||
[ReadStatus.ABANDONED]: 7,
|
||||
[ReadStatus.WONT_READ]: 8,
|
||||
};
|
||||
|
||||
private readonly fieldExtractors: Record<string, (book: Book) => unknown> = {
|
||||
title: (book) => (book.seriesCount ? (book.metadata?.seriesName?.toLowerCase() || null) : null)
|
||||
?? (book.metadata?.title?.toLowerCase() || null),
|
||||
titleSeries: (book) => {
|
||||
const title = book.metadata?.title?.toLowerCase() || '';
|
||||
const series = book.metadata?.seriesName?.toLowerCase();
|
||||
const seriesNumber = book.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER;
|
||||
if (series) {
|
||||
return [series, seriesNumber];
|
||||
}
|
||||
return [title, Number.MAX_SAFE_INTEGER];
|
||||
},
|
||||
title: (book) => book.metadata?.seriesName?.toLowerCase()
|
||||
|| book.metadata?.title?.toLowerCase()
|
||||
|| null,
|
||||
author: (book) => book.metadata?.authors?.map(a => a.toLowerCase()).join(", ") || null,
|
||||
authorSurnameVorname: (book) => book.metadata?.authors?.map(a => {
|
||||
const parts = a.trim().split(/\s+/);
|
||||
@@ -68,17 +72,6 @@ export class SortService {
|
||||
const firstname = parts.join(" ");
|
||||
return `${surname}, ${firstname}`.toLowerCase();
|
||||
}).join(", ") || null,
|
||||
authorSeries: (book) => {
|
||||
const author = book.metadata?.authors?.map(a => a.toLowerCase()).join(", ") || null;
|
||||
const series = book.metadata?.seriesName?.toLowerCase() || null;
|
||||
const seriesNumber = book.metadata?.seriesNumber ?? Number.MAX_SAFE_INTEGER;
|
||||
const title = book.metadata?.title?.toLowerCase() || '';
|
||||
if (series) {
|
||||
return [author, series, seriesNumber, title];
|
||||
}
|
||||
// For books without a series, use a very large string for series name to sort them last within an author.
|
||||
return [author, '~~~~~~~~~~~~~~~~~', Number.MAX_SAFE_INTEGER, title];
|
||||
},
|
||||
publishedDate: (book) => {
|
||||
const date = book.metadata?.publishedDate;
|
||||
return date === null || date === undefined ? null : new Date(date).getTime();
|
||||
@@ -105,6 +98,19 @@ export class SortService {
|
||||
fileName: (book) => book.fileName,
|
||||
filePath: (book) => book.filePath,
|
||||
random: (book) => Math.random(),
|
||||
seriesName: (book) => book.metadata?.seriesName?.toLowerCase() || null,
|
||||
seriesNumber: (book) => book.metadata?.seriesNumber ?? null,
|
||||
readStatus: (book) => book.readStatus ? (SortService.READ_STATUS_RANK[book.readStatus] ?? null) : null,
|
||||
dateFinished: (book) => book.dateFinished ? new Date(book.dateFinished).getTime() : null,
|
||||
readingProgress: (book) =>
|
||||
book.epubProgress?.percentage
|
||||
?? book.pdfProgress?.percentage
|
||||
?? book.cbxProgress?.percentage
|
||||
?? book.audiobookProgress?.percentage
|
||||
?? book.koreaderProgress?.percentage
|
||||
?? book.koboProgress?.percentage
|
||||
?? null,
|
||||
bookType: (book) => book.primaryFile?.bookType || null,
|
||||
};
|
||||
|
||||
applySort(books: Book[], selectedSort: SortOption | null): Book[] {
|
||||
|
||||
@@ -93,17 +93,21 @@ export class DashboardSettingsComponent implements OnInit {
|
||||
|
||||
this.sortFieldOptions = [
|
||||
{label: t('sortFields.title'), value: 'title'},
|
||||
{label: t('sortFields.titleSeries'), value: 'titleSeries'},
|
||||
{label: t('sortFields.fileName'), value: 'fileName'},
|
||||
{label: t('sortFields.filePath'), value: 'filePath'},
|
||||
{label: t('sortFields.addedOn'), value: 'addedOn'},
|
||||
{label: t('sortFields.author'), value: 'author'},
|
||||
{label: t('sortFields.authorSurnameVorname'), value: 'authorSurnameVorname'},
|
||||
{label: t('sortFields.authorSeries'), value: 'authorSeries'},
|
||||
{label: t('sortFields.seriesName'), value: 'seriesName'},
|
||||
{label: t('sortFields.seriesNumber'), value: 'seriesNumber'},
|
||||
{label: t('sortFields.personalRating'), value: 'personalRating'},
|
||||
{label: t('sortFields.publisher'), value: 'publisher'},
|
||||
{label: t('sortFields.publishedDate'), value: 'publishedDate'},
|
||||
{label: t('sortFields.lastReadTime'), value: 'lastReadTime'},
|
||||
{label: t('sortFields.readStatus'), value: 'readStatus'},
|
||||
{label: t('sortFields.dateFinished'), value: 'dateFinished'},
|
||||
{label: t('sortFields.readingProgress'), value: 'readingProgress'},
|
||||
{label: t('sortFields.bookType'), value: 'bookType'},
|
||||
{label: t('sortFields.pageCount'), value: 'pageCount'}
|
||||
];
|
||||
|
||||
|
||||
@@ -231,6 +231,13 @@ export const ALL_FILTER_OPTIONS: { label: string; value: VisibleFilterType }[] =
|
||||
{label: 'Comic Creator', value: 'comicCreator'}
|
||||
];
|
||||
|
||||
export const DEFAULT_VISIBLE_SORT_FIELDS: string[] = [
|
||||
'title', 'seriesName', 'fileName', 'filePath',
|
||||
'author', 'authorSurnameVorname', 'seriesNumber',
|
||||
'lastReadTime', 'personalRating', 'addedOn',
|
||||
'fileSizeKb', 'locked', 'publisher', 'publishedDate', 'pageCount', 'random'
|
||||
];
|
||||
|
||||
export interface UserSettings {
|
||||
perBookSetting: PerBookSetting;
|
||||
pdfReaderSetting: PdfReaderSetting;
|
||||
@@ -243,6 +250,7 @@ export interface UserSettings {
|
||||
sidebarMagicShelfSorting: SidebarMagicShelfSorting;
|
||||
filterMode: BookFilterMode;
|
||||
visibleFilters?: VisibleFilterType[];
|
||||
visibleSortFields?: string[];
|
||||
metadataCenterViewMode: 'route' | 'dialog';
|
||||
enableSeriesView: boolean;
|
||||
entityViewPreferences: EntityViewPreferences;
|
||||
|
||||
@@ -26,31 +26,41 @@
|
||||
|
||||
<div class="multi-sort-editor">
|
||||
<label class="multi-sort-label">{{ t('sortOrderLabel') }}</label>
|
||||
<app-multi-sort-popover
|
||||
[sortCriteria]="globalSortAsOptions"
|
||||
[availableSortOptions]="allSortAsOptions"
|
||||
(criteriaChange)="onGlobalSortCriteriaChange($event)">
|
||||
</app-multi-sort-popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-header-standalone">
|
||||
<label class="setting-label">{{ t('visibleSortFields') }}</label>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
{{ t('visibleSortFieldsDesc') }}
|
||||
<span class="sort-field-count">({{ sortFieldSelectionCountText }})</span>
|
||||
</p>
|
||||
<div class="sort-field-order-editor">
|
||||
<div
|
||||
class="sort-criteria-list"
|
||||
#sortFieldList
|
||||
class="sort-field-order-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>
|
||||
[cdkDropListData]="visibleSortFields"
|
||||
(cdkDropListDropped)="onSortFieldDrop($event)">
|
||||
@for (f of visibleSortFields; track f; let i = $index) {
|
||||
<div class="sort-field-order-row" cdkDrag>
|
||||
<div class="sort-field-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>
|
||||
<span class="sort-field-rank">{{ i + 1 }}.</span>
|
||||
<span class="sort-field-label">{{ getSortFieldLabel(f) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="direction-toggle-btn"
|
||||
(click)="toggleSortDirection(i)"
|
||||
[pTooltip]="criterion.direction === 'ASC' ? t('ascending') : t('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"
|
||||
class="remove-sort-field-btn"
|
||||
(click)="removeSortField(i)"
|
||||
[disabled]="visibleSortFields.length <= minSortFields"
|
||||
[pTooltip]="t('remove')"
|
||||
tooltipPosition="top">
|
||||
<i class="pi pi-times"></i>
|
||||
@@ -58,21 +68,29 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (availableSortFields.length > 0) {
|
||||
<div class="add-sort-row">
|
||||
<div class="sort-field-actions-row">
|
||||
@if (availableSortFieldsToAdd.length > 0 && visibleSortFields.length < maxSortFields) {
|
||||
<p-select
|
||||
size="small"
|
||||
[options]="availableSortFields"
|
||||
[(ngModel)]="selectedAddField"
|
||||
[options]="availableSortFieldsToAdd"
|
||||
[(ngModel)]="selectedAddSortField"
|
||||
optionLabel="label"
|
||||
optionValue="field"
|
||||
optionValue="value"
|
||||
[placeholder]="t('addSortField')"
|
||||
[style]="{ width: '200px' }"
|
||||
appendTo="body"
|
||||
(onChange)="addSortCriterion()">
|
||||
(onChange)="addSortField()">
|
||||
</p-select>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="reset-sort-fields-btn"
|
||||
(click)="resetSortFieldsToDefaults()"
|
||||
[pTooltip]="t('resetToDefaults')"
|
||||
tooltipPosition="top">
|
||||
<i class="pi pi-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,8 +119,7 @@
|
||||
<tr>
|
||||
<th style="min-width: 150px; max-width: 220px;">{{ t('tableType') }}</th>
|
||||
<th style="min-width: 220px; max-width: 280px;">{{ t('tableName') }}</th>
|
||||
<th style="min-width: 190px; max-width: 250px;">{{ t('tableSortBy') }}</th>
|
||||
<th style="min-width: 125px;">{{ t('tableDirection') }}</th>
|
||||
<th style="min-width: 160px;">{{ t('tableSortConfig') }}</th>
|
||||
<th style="min-width: 125px;">{{ t('tableViewMode') }}</th>
|
||||
<th style="width: 75px; text-align: center;">{{ t('tableActions') }}</th>
|
||||
</tr>
|
||||
@@ -124,13 +141,20 @@
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<p-select size="small" [options]="sortOptions" optionLabel="label" optionValue="field"
|
||||
[(ngModel)]="override.sort" [style]="{'width': '100%'}" appendTo="body"></p-select>
|
||||
</td>
|
||||
<td>
|
||||
<p-select size="small" [options]="sortDirectionOptions" [(ngModel)]="override.sortDir"
|
||||
optionLabel="label" optionValue="value"
|
||||
[style]="{'width': '100%'}" appendTo="body"></p-select>
|
||||
<p-button
|
||||
[label]="t('editSort')"
|
||||
icon="pi pi-sort"
|
||||
size="small"
|
||||
[outlined]="true"
|
||||
(onClick)="overrideSortPop.toggle($event)">
|
||||
</p-button>
|
||||
<p-popover #overrideSortPop [dismissable]="true" appendTo="body">
|
||||
<app-multi-sort-popover
|
||||
[sortCriteria]="override.sortCriteriaAsOptions"
|
||||
[availableSortOptions]="allSortAsOptions"
|
||||
(criteriaChange)="onOverrideSortCriteriaChange(rowIndex, $event)">
|
||||
</app-multi-sort-popover>
|
||||
</p-popover>
|
||||
</td>
|
||||
<td>
|
||||
<p-select size="small" [options]="viewModeOptions" [(ngModel)]="override.view"
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
// Multi-sort editor styles
|
||||
.multi-sort-editor {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
@@ -74,15 +73,28 @@
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.sort-criteria-list {
|
||||
// Visible sort fields editor styles
|
||||
.sort-field-count {
|
||||
font-weight: 500;
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.sort-field-order-editor {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.sort-field-order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-height: 40px;
|
||||
max-height: 320px;
|
||||
max-width: 350px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.sort-criterion-row {
|
||||
.sort-field-order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -102,7 +114,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background-color: var(--card-background);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: 6px;
|
||||
@@ -117,11 +129,11 @@
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sort-criteria-list.cdk-drop-list-dragging .sort-criterion-row:not(.cdk-drag-placeholder) {
|
||||
.sort-field-order-list.cdk-drop-list-dragging .sort-field-order-row:not(.cdk-drag-placeholder) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.criterion-drag-handle {
|
||||
.sort-field-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -137,20 +149,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.criterion-rank {
|
||||
.sort-field-rank {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
min-width: 1.5rem;
|
||||
min-width: 1.75rem;
|
||||
}
|
||||
|
||||
.criterion-label {
|
||||
.sort-field-label {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.direction-toggle-btn,
|
||||
.remove-criterion-btn {
|
||||
.remove-sort-field-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -160,6 +171,7 @@
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: var(--p-red-500);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
@@ -177,15 +189,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
.direction-toggle-btn {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.remove-criterion-btn {
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.add-sort-row {
|
||||
.sort-field-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reset-sort-fields-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;
|
||||
color: var(--text-secondary-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ground-background);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {Component, ElementRef, inject, OnDestroy, OnInit, viewChild} from '@angular/core';
|
||||
import {Button} from 'primeng/button';
|
||||
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Select} from 'primeng/select';
|
||||
import {TableModule} from 'primeng/table';
|
||||
import {SortCriterion, User, UserService} from '../../user-management/user.service';
|
||||
import {DEFAULT_VISIBLE_SORT_FIELDS, 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,8 +14,11 @@ 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';
|
||||
import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
import {SortDirection, SortOption} from '../../../book/model/sort.model';
|
||||
import {MultiSortPopoverComponent} from '../../../book/components/book-browser/sorting/multi-sort-popover/multi-sort-popover.component';
|
||||
import {Popover} from 'primeng/popover';
|
||||
import {CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop';
|
||||
|
||||
@Component({
|
||||
selector: 'app-view-preferences',
|
||||
@@ -28,10 +31,12 @@ import {TranslocoDirective, TranslocoService} from '@jsverse/transloco';
|
||||
ToastModule,
|
||||
Tooltip,
|
||||
ToggleSwitch,
|
||||
TranslocoDirective,
|
||||
MultiSortPopoverComponent,
|
||||
Popover,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
TranslocoDirective
|
||||
CdkDragHandle
|
||||
],
|
||||
templateUrl: './view-preferences.component.html',
|
||||
styleUrl: './view-preferences.component.scss'
|
||||
@@ -41,12 +46,12 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
sortOptions: {label: string; field: string; translationKey: string}[] = [
|
||||
{label: 'Title', field: 'title', translationKey: 'sortTitle'},
|
||||
{label: 'Title + Series', field: 'titleSeries', translationKey: 'sortTitleSeries'},
|
||||
{label: 'File Name', field: 'fileName', translationKey: 'sortFileName'},
|
||||
{label: 'File Path', field: 'filePath', translationKey: 'sortFilePath'},
|
||||
{label: 'Author', field: 'author', translationKey: 'sortAuthor'},
|
||||
{label: 'Author (Surname)', field: 'authorSurnameVorname', translationKey: 'sortAuthorSurname'},
|
||||
{label: 'Author + Series', field: 'authorSeries', translationKey: 'sortAuthorSeries'},
|
||||
{label: 'Series Name', field: 'seriesName', translationKey: 'sortSeriesName'},
|
||||
{label: 'Series Number', field: 'seriesNumber', translationKey: 'sortSeriesNumber'},
|
||||
{label: 'Last Read', field: 'lastReadTime', translationKey: 'sortLastRead'},
|
||||
{label: 'Personal Rating', field: 'personalRating', translationKey: 'sortPersonalRating'},
|
||||
{label: 'Added On', field: 'addedOn', translationKey: 'sortAddedOn'},
|
||||
@@ -54,6 +59,10 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
{label: 'Locked', field: 'locked', translationKey: 'sortLocked'},
|
||||
{label: 'Publisher', field: 'publisher', translationKey: 'sortPublisher'},
|
||||
{label: 'Published Date', field: 'publishedDate', translationKey: 'sortPublishedDate'},
|
||||
{label: 'Read Status', field: 'readStatus', translationKey: 'sortReadStatus'},
|
||||
{label: 'Date Finished', field: 'dateFinished', translationKey: 'sortDateFinished'},
|
||||
{label: 'Reading Progress', field: 'readingProgress', translationKey: 'sortReadingProgress'},
|
||||
{label: 'Book Type', field: 'bookType', translationKey: 'sortBookType'},
|
||||
{label: 'Amazon Rating', field: 'amazonRating', translationKey: 'sortAmazonRating'},
|
||||
{label: 'Amazon #', field: 'amazonReviewCount', translationKey: 'sortAmazonCount'},
|
||||
{label: 'Goodreads Rating', field: 'goodreadsRating', translationKey: 'sortGoodreadsRating'},
|
||||
@@ -71,11 +80,6 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
{label: 'Magic Shelf', value: 'MAGIC_SHELF', translationKey: 'entityMagicShelf'}
|
||||
];
|
||||
|
||||
sortDirectionOptions: {label: string; value: string; translationKey: string}[] = [
|
||||
{label: 'Ascending', value: 'ASC', translationKey: 'ascending'},
|
||||
{label: 'Descending', value: 'DESC', translationKey: 'descending'}
|
||||
];
|
||||
|
||||
viewModeOptions: {label: string; value: string; translationKey: string}[] = [
|
||||
{label: 'Grid', value: 'GRID', translationKey: 'viewGrid'},
|
||||
{label: 'Table', value: 'TABLE', translationKey: 'viewTable'}
|
||||
@@ -90,13 +94,26 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
selectedView: 'GRID' | 'TABLE' = 'GRID';
|
||||
autoSaveMetadata: boolean = false;
|
||||
sortCriteria: SortCriterion[] = [];
|
||||
selectedAddField: string | null = null;
|
||||
|
||||
// SortOption[] versions for the multi-sort-popover component
|
||||
globalSortAsOptions: SortOption[] = [];
|
||||
allSortAsOptions: SortOption[] = [];
|
||||
|
||||
// Visible sort fields configuration
|
||||
visibleSortFields: string[] = [];
|
||||
selectedAddSortField: string | null = null;
|
||||
readonly minSortFields = 3;
|
||||
readonly maxSortFields = 27;
|
||||
|
||||
private readonly sortFieldList = viewChild<ElementRef<HTMLElement>>('sortFieldList');
|
||||
|
||||
overrides: {
|
||||
entityType: 'LIBRARY' | 'SHELF' | 'MAGIC_SHELF';
|
||||
library: number;
|
||||
sort: string;
|
||||
sortDir: 'ASC' | 'DESC';
|
||||
sortCriteria: SortCriterion[];
|
||||
sortCriteriaAsOptions: SortOption[];
|
||||
view: 'GRID' | 'TABLE';
|
||||
}[] = [];
|
||||
|
||||
@@ -134,13 +151,32 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.sortCriteria = [{field: this.selectedSort, direction: this.selectedSortDir}];
|
||||
}
|
||||
|
||||
this.overrides = (prefs?.overrides ?? []).map(o => ({
|
||||
entityType: o.entityType,
|
||||
library: o.entityId,
|
||||
sort: o.preferences.sortKey,
|
||||
sortDir: o.preferences.sortDir ?? 'ASC',
|
||||
view: o.preferences.view ?? 'GRID'
|
||||
// Build SortOption[] versions for the popover
|
||||
this.allSortAsOptions = this.sortOptions.map(o => ({
|
||||
label: this.t.translate('settingsView.librarySort.' + o.translationKey),
|
||||
field: o.field,
|
||||
direction: SortDirection.ASCENDING
|
||||
}));
|
||||
this.globalSortAsOptions = this.toSortOptions(this.sortCriteria);
|
||||
|
||||
this.visibleSortFields = userState.user?.userSettings?.visibleSortFields
|
||||
? [...userState.user.userSettings.visibleSortFields]
|
||||
: [...DEFAULT_VISIBLE_SORT_FIELDS];
|
||||
|
||||
this.overrides = (prefs?.overrides ?? []).map(o => {
|
||||
const sc = o.preferences.sortCriteria?.length
|
||||
? [...o.preferences.sortCriteria]
|
||||
: [{field: o.preferences.sortKey, direction: o.preferences.sortDir ?? 'ASC'} as SortCriterion];
|
||||
return {
|
||||
entityType: o.entityType,
|
||||
library: o.entityId,
|
||||
sort: o.preferences.sortKey,
|
||||
sortDir: o.preferences.sortDir ?? 'ASC',
|
||||
sortCriteria: sc,
|
||||
sortCriteriaAsOptions: this.toSortOptions(sc),
|
||||
view: o.preferences.view ?? 'GRID'
|
||||
};
|
||||
});
|
||||
|
||||
this.libraryOptions = (librariesState.libraries ?? []).filter(lib => lib.id !== undefined).map(lib => ({
|
||||
label: lib.name,
|
||||
@@ -198,11 +234,14 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
addOverride(): void {
|
||||
const next = this.availableLibraries[0];
|
||||
if (next) {
|
||||
const defaultCriteria: SortCriterion[] = [{field: 'title', direction: 'ASC'}];
|
||||
this.overrides.push({
|
||||
entityType: next.entityType,
|
||||
library: next.value,
|
||||
sort: 'title',
|
||||
sortDir: 'ASC',
|
||||
sortCriteria: defaultCriteria,
|
||||
sortCriteriaAsOptions: this.toSortOptions(defaultCriteria),
|
||||
view: 'GRID'
|
||||
});
|
||||
}
|
||||
@@ -212,51 +251,87 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.overrides.splice(index, 1);
|
||||
}
|
||||
|
||||
// Multi-sort criteria methods
|
||||
get availableSortFields(): {label: string; field: string; translationKey: string}[] {
|
||||
const usedFields = new Set(this.sortCriteria.map(c => c.field));
|
||||
return this.sortOptions.filter(opt => !usedFields.has(opt.field));
|
||||
// Conversion helpers
|
||||
private toSortOptions(criteria: SortCriterion[]): SortOption[] {
|
||||
return criteria.map(c => ({
|
||||
label: this.t.translate('settingsView.librarySort.' + (this.sortOptions.find(o => o.field === c.field)?.translationKey ?? c.field)),
|
||||
field: c.field,
|
||||
direction: c.direction === 'ASC' ? SortDirection.ASCENDING : SortDirection.DESCENDING
|
||||
}));
|
||||
}
|
||||
|
||||
getSortLabel(field: string): string {
|
||||
const key = this.sortOptions.find(opt => opt.field === field)?.translationKey;
|
||||
return key ? this.t.translate('settingsView.librarySort.' + key) : field;
|
||||
private toSortCriteria(options: SortOption[]): SortCriterion[] {
|
||||
return options.map(o => ({
|
||||
field: o.field,
|
||||
direction: o.direction === SortDirection.ASCENDING ? 'ASC' as const : 'DESC' as const
|
||||
}));
|
||||
}
|
||||
|
||||
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';
|
||||
onGlobalSortCriteriaChange(criteria: SortOption[]): void {
|
||||
this.globalSortAsOptions = criteria;
|
||||
this.sortCriteria = this.toSortCriteria(criteria);
|
||||
this.syncLegacySort();
|
||||
}
|
||||
|
||||
onSortCriteriaDrop(event: CdkDragDrop<SortCriterion[]>): void {
|
||||
moveItemInArray(this.sortCriteria, event.previousIndex, event.currentIndex);
|
||||
this.syncLegacySort();
|
||||
onOverrideSortCriteriaChange(index: number, criteria: SortOption[]): void {
|
||||
this.overrides[index].sortCriteriaAsOptions = criteria;
|
||||
this.overrides[index].sortCriteria = this.toSortCriteria(criteria);
|
||||
this.overrides[index].sort = criteria[0]?.field ?? 'title';
|
||||
this.overrides[index].sortDir = criteria[0]?.direction === SortDirection.ASCENDING ? 'ASC' : 'DESC';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Visible sort fields management
|
||||
getSortFieldLabel(field: string): string {
|
||||
const key = this.sortOptions.find(opt => opt.field === field)?.translationKey;
|
||||
return key ? this.t.translate('settingsView.librarySort.' + key) : field;
|
||||
}
|
||||
|
||||
get availableSortFieldsToAdd(): {label: string; value: string}[] {
|
||||
const used = new Set(this.visibleSortFields);
|
||||
return this.sortOptions
|
||||
.filter(opt => !used.has(opt.field))
|
||||
.map(opt => ({label: this.t.translate('settingsView.librarySort.' + opt.translationKey), value: opt.field}));
|
||||
}
|
||||
|
||||
get sortFieldSelectionCountText(): string {
|
||||
return this.t.translate('settingsView.librarySort.sortFieldCount', {
|
||||
count: this.visibleSortFields.length,
|
||||
total: this.sortOptions.length
|
||||
});
|
||||
}
|
||||
|
||||
onSortFieldDrop(event: CdkDragDrop<string[]>): void {
|
||||
moveItemInArray(this.visibleSortFields, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
|
||||
addSortField(): void {
|
||||
if (this.selectedAddSortField) {
|
||||
this.visibleSortFields.push(this.selectedAddSortField);
|
||||
this.selectedAddSortField = null;
|
||||
requestAnimationFrame(() => {
|
||||
const el = this.sortFieldList()?.nativeElement;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeSortField(index: number): void {
|
||||
if (this.visibleSortFields.length > this.minSortFields) {
|
||||
this.visibleSortFields.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
resetSortFieldsToDefaults(): void {
|
||||
this.visibleSortFields = [...DEFAULT_VISIBLE_SORT_FIELDS];
|
||||
}
|
||||
|
||||
saveSettings(): void {
|
||||
if (!this.user) return;
|
||||
|
||||
@@ -279,8 +354,9 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
entityType: o.entityType,
|
||||
entityId: o.library,
|
||||
preferences: {
|
||||
sortKey: o.sort,
|
||||
sortDir: o.sortDir,
|
||||
sortKey: o.sortCriteria[0]?.field ?? o.sort,
|
||||
sortDir: o.sortCriteria[0]?.direction ?? o.sortDir,
|
||||
sortCriteria: [...o.sortCriteria],
|
||||
view: o.view,
|
||||
coverSize: existing?.coverSize ?? 1.0,
|
||||
seriesCollapsed: existing?.seriesCollapsed ?? false,
|
||||
@@ -291,6 +367,7 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.userService.updateUserSetting(this.user.id, 'entityViewPreferences', prefs);
|
||||
this.userService.updateUserSetting(this.user.id, 'autoSaveMetadata', this.autoSaveMetadata);
|
||||
this.userService.updateUserSetting(this.user.id, 'visibleSortFields', this.visibleSortFields);
|
||||
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div>
|
||||
<ul class="layout-menu">
|
||||
@if (homeMenu$ | async; as homeMenu) {
|
||||
@for (item of homeMenu; track item; let i = $index) {
|
||||
@for (item of homeMenu; track item.label; let i = $index) {
|
||||
@if (!item.separator) {
|
||||
<li app-menuitem [item]="item" [index]="i" [root]="true" menuKey="home"></li>
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<ul class="layout-menu">
|
||||
@if (libraryMenu$ | async; as libraryMenu) {
|
||||
@for (item of libraryMenu; track item; let i = $index) {
|
||||
@for (item of libraryMenu; track item.label; let i = $index) {
|
||||
@if (!item.separator) {
|
||||
<li app-menuitem [item]="item" [index]="i" [root]="true" menuKey="library"></li>
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<ul class="layout-menu">
|
||||
@if (shelfMenu$ | async; as shelfMenu) {
|
||||
@for (item of shelfMenu; track item; let i = $index) {
|
||||
@for (item of shelfMenu; track item.label; let i = $index) {
|
||||
@if (!item.separator) {
|
||||
<li app-menuitem [item]="item" [index]="i" [root]="true" menuKey="shelf"></li>
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<ul class="layout-menu">
|
||||
@if (magicShelfMenu$ | async; as shelfMenu) {
|
||||
@for (item of shelfMenu; track item; let i = $index) {
|
||||
@for (item of shelfMenu; track item.label; let i = $index) {
|
||||
@if (!item.separator) {
|
||||
<li app-menuitem [item]="item" [index]="i" [root]="true" menuKey="magicShelf"></li>
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<div>
|
||||
<div
|
||||
class="menu-item-container"
|
||||
[class.active-route-container]="isRouteActive"
|
||||
(click)="triggerLink()"
|
||||
>
|
||||
@if ((item.routerLink && !item.items)) {
|
||||
@@ -39,9 +40,8 @@
|
||||
(click)="itemClick($event)"
|
||||
[ngClass]="item.class"
|
||||
class="menu-item-link"
|
||||
[class.active-route]="isRouteActive"
|
||||
[routerLink]="item.routerLink"
|
||||
routerLinkActive="active-route"
|
||||
[routerLinkActiveOptions]="item.routerLinkActiveOptions || { paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||
[fragment]="item.fragment"
|
||||
[queryParamsHandling]="item.queryParamsHandling"
|
||||
[preserveFragment]="item.preserveFragment"
|
||||
@@ -94,7 +94,7 @@
|
||||
<div [@children]="isExpanded(key) ? 'expanded' : 'collapsed'" class="submenu-container">
|
||||
@if (item.items) {
|
||||
<ul>
|
||||
@for (child of item.items; track child; let i = $index) {
|
||||
@for (child of item.items; track child.routerLink?.[0] ?? child.label; let i = $index) {
|
||||
<li app-menuitem [item]="child" [index]="i" [parentKey]="key" [class]="child.badgeClass"></li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
:host(.active-menuitem) .menu-item-container {
|
||||
.menu-item-container.active-route-container {
|
||||
background-color: color-mix(in srgb, var(--p-primary-300), transparent 93%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Component, ElementRef, HostBinding, Input, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {NavigationEnd, Router, RouterLink, RouterLinkActive} from '@angular/router';
|
||||
import {NavigationEnd, Router, RouterLink} from '@angular/router';
|
||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||
import {Subscription} from 'rxjs';
|
||||
import {filter} from 'rxjs/operators';
|
||||
@@ -21,7 +21,6 @@ import {IconSelection} from '../../../service/icon-picker.service';
|
||||
styleUrls: ['./app.menuitem.component.scss'],
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
NgClass,
|
||||
Ripple,
|
||||
AsyncPipe,
|
||||
@@ -55,8 +54,15 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
|
||||
canManipulateLibrary: boolean = false;
|
||||
admin: boolean = false;
|
||||
expandedItems = new Set<string>();
|
||||
|
||||
get isRouteActive(): boolean {
|
||||
if (!this.item?.routerLink?.[0]) return false;
|
||||
return this.router.url.split('?')[0] === this.item.routerLink[0];
|
||||
}
|
||||
|
||||
menuSourceSubscription: Subscription;
|
||||
menuResetSubscription: Subscription;
|
||||
private routerSubscription: Subscription;
|
||||
|
||||
constructor(
|
||||
public router: Router,
|
||||
@@ -88,7 +94,7 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
|
||||
this.active = false;
|
||||
});
|
||||
|
||||
this.router.events.pipe(filter(event => event instanceof NavigationEnd))
|
||||
this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
if (this.item.routerLink) {
|
||||
this.updateActiveStateFromRoute();
|
||||
@@ -112,6 +118,9 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
|
||||
if (this.menuResetSubscription) {
|
||||
this.menuResetSubscription.unsubscribe();
|
||||
}
|
||||
if (this.routerSubscription) {
|
||||
this.routerSubscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpand(key: string) {
|
||||
@@ -181,8 +190,4 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
@HostBinding('class.active-menuitem')
|
||||
get activeClass() {
|
||||
return this.active && !this.root;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,17 +41,21 @@
|
||||
},
|
||||
"sortFields": {
|
||||
"title": "Title",
|
||||
"titleSeries": "Title + Series",
|
||||
"fileName": "File Name",
|
||||
"filePath": "File Path",
|
||||
"addedOn": "Date Added",
|
||||
"author": "Author",
|
||||
"authorSurnameVorname": "Author (Surname)",
|
||||
"authorSeries": "Author + Series",
|
||||
"seriesName": "Series Name",
|
||||
"seriesNumber": "Series Number",
|
||||
"personalRating": "Personal Rating",
|
||||
"publisher": "Publisher",
|
||||
"publishedDate": "Published Date",
|
||||
"lastReadTime": "Last Read",
|
||||
"readStatus": "Read Status",
|
||||
"dateFinished": "Date Finished",
|
||||
"readingProgress": "Reading Progress",
|
||||
"bookType": "Book Type",
|
||||
"pageCount": "Pages"
|
||||
},
|
||||
"sortDirections": {
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
"saveSuccess": "Preferences Saved",
|
||||
"saveSuccessDetail": "Your sorting and view preferences were saved successfully.",
|
||||
"sortTitle": "Title",
|
||||
"sortTitleSeries": "Title + Series",
|
||||
"sortFileName": "File Name",
|
||||
"sortFilePath": "File Path",
|
||||
"sortAuthor": "Author",
|
||||
"sortAuthorSurname": "Author (Surname)",
|
||||
"sortAuthorSeries": "Author + Series",
|
||||
"sortSeriesName": "Series Name",
|
||||
"sortSeriesNumber": "Series Number",
|
||||
"sortLastRead": "Last Read",
|
||||
"sortPersonalRating": "Personal Rating",
|
||||
"sortAddedOn": "Added On",
|
||||
@@ -47,13 +47,26 @@
|
||||
"sortHardcoverRating": "Hardcover Rating",
|
||||
"sortHardcoverCount": "Hardcover #",
|
||||
"sortRanobedbRating": "Ranobedb Rating",
|
||||
"sortReadStatus": "Read Status",
|
||||
"sortDateFinished": "Date Finished",
|
||||
"sortReadingProgress": "Reading Progress",
|
||||
"sortBookType": "Book Type",
|
||||
"sortPages": "Pages",
|
||||
"sortRandom": "Random",
|
||||
"entityLibrary": "Library",
|
||||
"entityShelf": "Shelf",
|
||||
"entityMagicShelf": "Magic Shelf",
|
||||
"viewGrid": "Grid",
|
||||
"viewTable": "Table"
|
||||
"viewTable": "Table",
|
||||
"saveSortDefault": "Save as Default",
|
||||
"sortSaved": "Sort Saved",
|
||||
"sortSavedDetail": "Sort configuration saved for this {{entityType}}.",
|
||||
"editSort": "Edit Sort",
|
||||
"tableSortConfig": "Sort Config",
|
||||
"visibleSortFields": "Visible Sort Fields",
|
||||
"visibleSortFieldsDesc": "Choose which sort fields appear in the book browser's sort dropdown and drag to reorder.",
|
||||
"sortFieldCount": "{{count}} of {{total}} selected",
|
||||
"resetToDefaults": "Reset to defaults"
|
||||
},
|
||||
"filter": {
|
||||
"sectionTitle": "Filter Preferences",
|
||||
|
||||
@@ -41,17 +41,21 @@
|
||||
},
|
||||
"sortFields": {
|
||||
"title": "Título",
|
||||
"titleSeries": "Título + Serie",
|
||||
"fileName": "Nombre de archivo",
|
||||
"filePath": "Ruta de archivo",
|
||||
"addedOn": "Fecha de adición",
|
||||
"author": "Autor",
|
||||
"authorSurnameVorname": "Autor (Apellido)",
|
||||
"authorSeries": "Autor + Serie",
|
||||
"seriesName": "Nombre de serie",
|
||||
"seriesNumber": "Número de serie",
|
||||
"personalRating": "Valoración personal",
|
||||
"publisher": "Editorial",
|
||||
"publishedDate": "Fecha de publicación",
|
||||
"lastReadTime": "Última lectura",
|
||||
"readStatus": "Estado de lectura",
|
||||
"dateFinished": "Fecha de finalización",
|
||||
"readingProgress": "Progreso de lectura",
|
||||
"bookType": "Tipo de libro",
|
||||
"pageCount": "Páginas"
|
||||
},
|
||||
"sortDirections": {
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
"saveSuccess": "Preferencias guardadas",
|
||||
"saveSuccessDetail": "Tus preferencias de ordenación y vista se guardaron correctamente.",
|
||||
"sortTitle": "Título",
|
||||
"sortTitleSeries": "Título + Serie",
|
||||
"sortFileName": "Nombre de archivo",
|
||||
"sortFilePath": "Ruta de archivo",
|
||||
"sortAuthor": "Autor",
|
||||
"sortAuthorSurname": "Autor (Apellido)",
|
||||
"sortAuthorSeries": "Autor + Serie",
|
||||
"sortSeriesName": "Nombre de serie",
|
||||
"sortSeriesNumber": "Número de serie",
|
||||
"sortLastRead": "Última lectura",
|
||||
"sortPersonalRating": "Valoración personal",
|
||||
"sortAddedOn": "Añadido el",
|
||||
@@ -47,13 +47,26 @@
|
||||
"sortHardcoverRating": "Valoración Hardcover",
|
||||
"sortHardcoverCount": "Hardcover #",
|
||||
"sortRanobedbRating": "Valoración Ranobedb",
|
||||
"sortReadStatus": "Estado de lectura",
|
||||
"sortDateFinished": "Fecha de finalización",
|
||||
"sortReadingProgress": "Progreso de lectura",
|
||||
"sortBookType": "Tipo de libro",
|
||||
"sortPages": "Páginas",
|
||||
"sortRandom": "Aleatorio",
|
||||
"entityLibrary": "Biblioteca",
|
||||
"entityShelf": "Estante",
|
||||
"entityMagicShelf": "Estante mágico",
|
||||
"viewGrid": "Cuadrícula",
|
||||
"viewTable": "Tabla"
|
||||
"viewTable": "Tabla",
|
||||
"saveSortDefault": "Guardar como predeterminado",
|
||||
"sortSaved": "Orden guardado",
|
||||
"sortSavedDetail": "Configuración de orden guardada para este/a {{entityType}}.",
|
||||
"editSort": "Editar orden",
|
||||
"tableSortConfig": "Configuración de orden",
|
||||
"visibleSortFields": "Campos de orden visibles",
|
||||
"visibleSortFieldsDesc": "Elige qué campos de orden aparecen en el menú de orden del navegador de libros y arrastra para reordenar.",
|
||||
"sortFieldCount": "{{count}} de {{total}} seleccionados",
|
||||
"resetToDefaults": "Restablecer valores predeterminados"
|
||||
},
|
||||
"filter": {
|
||||
"sectionTitle": "Preferencias de filtros",
|
||||
|
||||
Reference in New Issue
Block a user