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:
ACX
2026-02-10 14:27:07 -07:00
committed by GitHub
parent 78a8bb4fea
commit 0fa84e3424
23 changed files with 509 additions and 190 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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