mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
refactor: reduce re-renders and improve filter sidebar performance (#2440)
Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
@@ -13,12 +13,12 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-content">
|
||||
<p-accordion [value]="expandedPanels" [multiple]="true">
|
||||
<p-accordion [value]="expandedPanels" [multiple]="true" (valueChange)="onExpandedPanelsChange($event)">
|
||||
@for (filterType of filterTypes; track trackByFilterType(i, filterType); let i = $index) {
|
||||
@if (filterStreams[filterType] | async; as filters) {
|
||||
@if (filters.length > 0) {
|
||||
<p-accordion-panel [value]="i">
|
||||
<p-accordion-header>
|
||||
<p-accordion-panel [value]="i">
|
||||
<p-accordion-header>
|
||||
<span class="filter-type-label">
|
||||
{{ filterLabels[filterType] || (filterType | titlecase) }}
|
||||
@if (activeFilters[filterType]?.length) {
|
||||
@@ -27,29 +27,33 @@
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</p-accordion-header>
|
||||
</p-accordion-header>
|
||||
|
||||
<p-accordion-content>
|
||||
<div class="filter-list">
|
||||
@for (filter of filters; track trackByFilter(j, filter); let j = $index) {
|
||||
<div
|
||||
class="filter-row"
|
||||
[ngClass]="{
|
||||
<p-accordion-content>
|
||||
@if (expandedPanels.includes(i)) {
|
||||
<cdk-virtual-scroll-viewport
|
||||
[itemSize]="28"
|
||||
[style.height.px]="getVirtualScrollHeight(filters.length)"
|
||||
class="filter-list">
|
||||
<div
|
||||
*cdkVirtualFor="let filter of filters; trackBy: trackByFilter"
|
||||
class="filter-row"
|
||||
[ngClass]="{
|
||||
'active': activeFilters[filterType]?.includes(getFilterValueId(filter))
|
||||
}"
|
||||
(click)="handleFilterClick(filterType, getFilterValueId(filter))">
|
||||
{{ getFilterValueDisplay(filter) }}
|
||||
<p-badge class="filter-value-badge" [value]="filter.bookCount"></p-badge>
|
||||
</div>
|
||||
}
|
||||
@if (truncatedFilters[filterType]) {
|
||||
<div class="truncation-notice">
|
||||
Showing first 500 items
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
(click)="handleFilterClick(filterType, getFilterValueId(filter))">
|
||||
{{ getFilterValueDisplay(filter) }}
|
||||
<p-badge class="filter-value-badge" [value]="filter.bookCount"></p-badge>
|
||||
</div>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
@if (truncatedFilters[filterType]) {
|
||||
<div class="truncation-notice">
|
||||
Showing first 500 items
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,16 +51,13 @@
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
max-height: 27.5rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
cursor: pointer;
|
||||
transition: colors 200ms ease-in-out;
|
||||
padding-bottom: 0.25rem;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {Shelf} from '../../../model/shelf.model';
|
||||
import {EntityType} from '../book-browser.component';
|
||||
import {Book, ReadStatus} from '../../../model/book.model';
|
||||
import {Accordion, AccordionContent, AccordionHeader, AccordionPanel} from 'primeng/accordion';
|
||||
import {CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
|
||||
import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common';
|
||||
import {Badge} from 'primeng/badge';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
@@ -28,8 +29,6 @@ export interface Filter<T extends FilterValue = FilterValue> {
|
||||
}
|
||||
|
||||
export type FilterType =
|
||||
| 'library'
|
||||
| 'shelf'
|
||||
| 'author'
|
||||
| 'category'
|
||||
| 'series'
|
||||
@@ -42,13 +41,10 @@ export type FilterType =
|
||||
| 'tag'
|
||||
| 'language'
|
||||
| 'bookType'
|
||||
| 'shelfStatus'
|
||||
| 'fileSize'
|
||||
| 'pageCount'
|
||||
| 'amazonRating'
|
||||
| 'goodreadsRating'
|
||||
| 'ranobedbRating'
|
||||
| 'hardcoverRating';
|
||||
| 'goodreadsRating';
|
||||
|
||||
export const ratingRanges = [
|
||||
{id: '0to1', label: '0 to 1', min: 0, max: 1, sortIndex: 0},
|
||||
@@ -176,6 +172,9 @@ function getReadStatusName(status?: ReadStatus | null): string {
|
||||
AccordionPanel,
|
||||
AccordionHeader,
|
||||
AccordionContent,
|
||||
CdkVirtualScrollViewport,
|
||||
CdkFixedSizeVirtualScroll,
|
||||
CdkVirtualForOf,
|
||||
NgClass,
|
||||
Badge,
|
||||
AsyncPipe,
|
||||
@@ -207,8 +206,6 @@ export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
private _selectedFilterMode: BookFilterMode = 'and';
|
||||
expandedPanels: number[] = [0];
|
||||
readonly filterLabels: Record<FilterType, string> = {
|
||||
library: 'Library',
|
||||
shelf: 'Shelf',
|
||||
author: 'Author',
|
||||
category: 'Genre',
|
||||
series: 'Series',
|
||||
@@ -221,13 +218,10 @@ export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
tag: 'Tag',
|
||||
language: 'Language',
|
||||
bookType: 'Book Type',
|
||||
shelfStatus: 'Shelf Status',
|
||||
fileSize: 'File Size',
|
||||
pageCount: 'Page Count',
|
||||
amazonRating: 'Amazon Rating',
|
||||
goodreadsRating: 'Goodreads Rating',
|
||||
hardcoverRating: 'Hardcover Rating',
|
||||
ranobedbRating: 'Ranobedb Rating',
|
||||
goodreadsRating: 'Goodreads Rating'
|
||||
};
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -253,14 +247,6 @@ export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.filterStreams = {
|
||||
library: this.getFilterStream(
|
||||
(book) => (book.libraryId ? [{id: book.libraryId, name: book.libraryName}] : []),
|
||||
'id', 'name'
|
||||
),
|
||||
shelf: this.getFilterStream(
|
||||
(book) => (book.shelves ? book.shelves.map(s => ({id: s.id, name: s.name})) : []),
|
||||
'id', 'name'
|
||||
),
|
||||
author: this.getFilterStream(
|
||||
(book: Book) => Array.isArray(book.metadata?.authors) ? book.metadata.authors.map(name => ({id: name, name})) : [],
|
||||
'id', 'name'
|
||||
@@ -297,13 +283,10 @@ export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
),
|
||||
language: this.getFilterStream(getLanguageFilter, 'id', 'name'),
|
||||
bookType: this.getFilterStream(getBookTypeFilter, 'id', 'name'),
|
||||
shelfStatus: this.getFilterStream(getShelfStatusFilter, 'id', 'name'),
|
||||
fileSize: this.getFilterStream((book: Book) => getFileSizeRangeFilters(book.fileSizeKb), 'id', 'name', 'sortIndex'),
|
||||
pageCount: this.getFilterStream((book: Book) => getPageCountRangeFilters(book.metadata?.pageCount ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
amazonRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.amazonRating ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
hardcoverRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.hardcoverRating ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
ranobedbRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.ranobedbRating ?? undefined), 'id', 'name', 'sortIndex'),
|
||||
goodreadsRating: this.getFilterStream((book: Book) => getRatingRangeFilters(book.metadata?.goodreadsRating ?? undefined), 'id', 'name', 'sortIndex')
|
||||
};
|
||||
|
||||
this.filterTypes = Object.keys(this.filterStreams) as FilterType[];
|
||||
@@ -460,18 +443,28 @@ export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
|
||||
clearActiveFilter() {
|
||||
this.activeFilters = {};
|
||||
this.setExpandedPanels();
|
||||
this.expandedPanels = [0];
|
||||
this.filterChangeSubject.next(null);
|
||||
}
|
||||
|
||||
onExpandedPanelsChange(value: string | number | string[] | number[] | null | undefined): void {
|
||||
if (Array.isArray(value)) {
|
||||
this.expandedPanels = value.map(v => Number(v));
|
||||
}
|
||||
}
|
||||
|
||||
getVirtualScrollHeight(itemCount: number): number {
|
||||
return Math.min(itemCount * 28, 440);
|
||||
}
|
||||
|
||||
setExpandedPanels(): void {
|
||||
const indexes = [];
|
||||
const current = new Set(this.expandedPanels);
|
||||
for (let i = 0; i < this.filterTypes.length; i++) {
|
||||
if (this.activeFilters[this.filterTypes[i]]?.length) {
|
||||
indexes.push(i);
|
||||
current.add(i);
|
||||
}
|
||||
}
|
||||
this.expandedPanels = indexes.length > 0 ? indexes : [0];
|
||||
this.expandedPanels = current.size > 0 ? [...current] : [0];
|
||||
}
|
||||
|
||||
onFiltersChanged(): void {
|
||||
@@ -484,18 +477,18 @@ export class BookFilterComponent implements OnInit, OnDestroy {
|
||||
|
||||
trackByFilter(_: number, filter: Filter<FilterValue>): unknown {
|
||||
const value = filter.value as { id?: unknown } | unknown;
|
||||
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as {id: unknown}).id : filter.value;
|
||||
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as { id: unknown }).id : filter.value;
|
||||
}
|
||||
|
||||
getFilterValueId(filter: Filter<FilterValue>): unknown {
|
||||
const value = filter.value as { id?: unknown } | unknown;
|
||||
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as {id: unknown}).id : filter.value;
|
||||
return (typeof value === 'object' && value !== null && 'id' in value) ? (value as { id: unknown }).id : filter.value;
|
||||
}
|
||||
|
||||
getFilterValueDisplay(filter: Filter<FilterValue>): string {
|
||||
const value = filter.value as { name?: string } | string | unknown;
|
||||
if (typeof value === 'object' && value !== null && 'name' in value) {
|
||||
return String((value as {name: string}).name ?? '');
|
||||
return String((value as { name: string }).name ?? '');
|
||||
}
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user