mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Merge pull request #2458 from booklore-app/develop
Merge develop into master for release
This commit is contained in:
@@ -122,7 +122,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
|
||||
""")
|
||||
Page<Long> findBookIdsByMetadataSearchAndShelfIds(@Param("text") String text, @Param("shelfIds") Collection<Long> shelfIds, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.authors", "metadata.categories", "additionalFiles", "shelves"})
|
||||
@EntityGraph(attributePaths = {"metadata", "metadata.authors", "metadata.categories", "bookFiles", "shelves"})
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE b.id IN :ids AND s.id IN :shelfIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithFullMetadataByIdsAndShelfIds(@Param("ids") Collection<Long> ids, @Param("shelfIds") Collection<Long> shelfIds);
|
||||
|
||||
@@ -133,7 +133,7 @@ public interface BookOpdsRepository extends JpaRepository<BookEntity, Long>, Jpa
|
||||
@Query("SELECT DISTINCT b.id FROM BookEntity b JOIN b.shelves s WHERE s.id IN :shelfIds AND (b.deleted IS NULL OR b.deleted = false) ORDER BY b.addedOn DESC")
|
||||
Page<Long> findBookIdsByShelfIds(@Param("shelfIds") Collection<Long> shelfIds, Pageable pageable);
|
||||
|
||||
@EntityGraph(attributePaths = {"metadata", "additionalFiles", "shelves"})
|
||||
@EntityGraph(attributePaths = {"metadata", "bookFiles", "shelves"})
|
||||
@Query("SELECT DISTINCT b FROM BookEntity b JOIN b.shelves s WHERE b.id IN :ids AND s.id IN :shelfIds AND (b.deleted IS NULL OR b.deleted = false)")
|
||||
List<BookEntity> findAllWithMetadataByIdsAndShelfIds(@Param("ids") Collection<Long> ids, @Param("shelfIds") Collection<Long> shelfIds);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"resources": {
|
||||
"files": [
|
||||
"/favicon.ico",
|
||||
"/assets/favicon.svg",
|
||||
"/index.csr.html",
|
||||
"/index.html",
|
||||
"/manifest.webmanifest",
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
@if (loading) {
|
||||
<div class="splash-screen">
|
||||
<div class="splash-content">
|
||||
<img src="assets/favicon.svg" alt="Booklore logo" class="logo" />
|
||||
<h1>Loading Booklore…</h1>
|
||||
<p>Please wait while we get things ready.</p>
|
||||
<div class="loader"></div>
|
||||
<svg class="logo" viewBox="0 0 126 126" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M59 4.79297C71.5051 11.5557 80 24.7854 80 40C80 40.5959 79.987 41.1888 79.9609 41.7783C79.8609 44.0406 81.7355 46 84 46C106.091 46 124 63.9086 124 86C124 108.091 106.091 126 84 126H10C4.47715 126 0 121.523 0 116V39.0068L0.0126953 38.9941C0.357624 25.0252 7.86506 12.8347 19 5.95215V63.832C19 64.8345 20.0676 65.4391 20.9121 64.9902L21.0771 64.8867L38.2227 52.3428C38.6819 52.0068 39.3064 52.0068 39.7656 52.3428L56.9229 64.8945L57.0879 64.998C57.9324 65.447 59 64.8423 59 63.8398V4.79297Z" fill="#818cf8"/>
|
||||
<path d="M40 0C43.8745 0 47.6199 0.552381 51.1631 1.58008V50.9697L44.3926 46.0176L44.0879 45.8037C40.9061 43.6679 36.7098 43.7393 33.5957 46.0176L26.8369 50.9619V2.21875C30.9593 0.782634 35.3881 0 40 0Z" fill="white"/>
|
||||
</svg>
|
||||
@if (offline) {
|
||||
<h1>You're Offline</h1>
|
||||
<p>BookLore needs a connection to the server to load.</p>
|
||||
<p>Check your network and try again.</p>
|
||||
} @else {
|
||||
<h1>Loading Booklore…</h1>
|
||||
<p>Please wait while we get things ready.</p>
|
||||
<div class="loader"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else if (offline) {
|
||||
<div class="splash-screen">
|
||||
<div class="splash-content">
|
||||
<svg class="logo" viewBox="0 0 126 126" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M59 4.79297C71.5051 11.5557 80 24.7854 80 40C80 40.5959 79.987 41.1888 79.9609 41.7783C79.8609 44.0406 81.7355 46 84 46C106.091 46 124 63.9086 124 86C124 108.091 106.091 126 84 126H10C4.47715 126 0 121.523 0 116V39.0068L0.0126953 38.9941C0.357624 25.0252 7.86506 12.8347 19 5.95215V63.832C19 64.8345 20.0676 65.4391 20.9121 64.9902L21.0771 64.8867L38.2227 52.3428C38.6819 52.0068 39.3064 52.0068 39.7656 52.3428L56.9229 64.8945L57.0879 64.998C57.9324 65.447 59 64.8423 59 63.8398V4.79297Z" fill="#818cf8"/>
|
||||
<path d="M40 0C43.8745 0 47.6199 0.552381 51.1631 1.58008V50.9697L44.3926 46.0176L44.0879 45.8037C40.9061 43.6679 36.7098 43.7393 33.5957 46.0176L26.8369 50.9619V2.21875C30.9593 0.782634 35.3881 0 40 0Z" fill="white"/>
|
||||
</svg>
|
||||
<h1>You're Offline</h1>
|
||||
<p>Your connection was lost. Some features may not work.</p>
|
||||
<button class="retry-btn" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
@@ -47,3 +47,19 @@ p {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem 1.5rem;
|
||||
background-color: #818cf8;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #6366f1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {scan, withLatestFrom} from 'rxjs/operators';
|
||||
export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
loading = true;
|
||||
offline = !navigator.onLine;
|
||||
private subscriptions: Subscription[] = [];
|
||||
private subscriptionsInitialized = false;
|
||||
|
||||
@@ -43,6 +44,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private libraryLoadingService = inject(LibraryLoadingService);
|
||||
|
||||
ngOnInit(): void {
|
||||
window.addEventListener('online', this.onOnline);
|
||||
window.addEventListener('offline', this.onOffline);
|
||||
|
||||
this.authInit.initialized$.subscribe(ready => {
|
||||
this.loading = !ready;
|
||||
if (ready && !this.subscriptionsInitialized) {
|
||||
@@ -52,6 +56,18 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private onOnline = () => {
|
||||
this.offline = false;
|
||||
};
|
||||
|
||||
private onOffline = () => {
|
||||
this.offline = true;
|
||||
};
|
||||
|
||||
reload(): void {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
private setupWebSocketSubscriptions(): void {
|
||||
this.subscriptions.push(
|
||||
this.rxStompService.watch('/user/queue/book-add').pipe(
|
||||
@@ -125,6 +141,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
window.removeEventListener('online', this.onOnline);
|
||||
window.removeEventListener('offline', this.onOffline);
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe());
|
||||
this.libraryLoadingService.hide();
|
||||
}
|
||||
|
||||
@@ -1,39 +1,78 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from '@angular/router';
|
||||
import {BookBrowserScrollService} from '../features/book/components/book-browser/book-browser-scroll.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CustomReuseStrategy implements RouteReuseStrategy {
|
||||
private storedRoutes = new Map<string, DetachedRouteHandle>();
|
||||
private scrollService = inject(BookBrowserScrollService);
|
||||
|
||||
// Only detach the route if it's for the book details page
|
||||
shouldDetach(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.routeConfig?.path === 'book/:id'; // Match the path of the route you want to reuse
|
||||
private readonly BOOK_BROWSER_PATHS = [
|
||||
'all-books',
|
||||
'unshelved-books',
|
||||
'library/:libraryId/books',
|
||||
'shelf/:shelfId/books',
|
||||
'magic-shelf/:magicShelfId/books'
|
||||
];
|
||||
|
||||
private readonly BOOK_DETAILS_PATH = 'book/:bookId';
|
||||
|
||||
private getRouteKey(route: ActivatedRouteSnapshot): string {
|
||||
const path = route.routeConfig?.path || '';
|
||||
return this.scrollService.createKey(path, route.params);
|
||||
}
|
||||
|
||||
private isBookBrowserRoute(route: ActivatedRouteSnapshot): boolean {
|
||||
const path = route.routeConfig?.path;
|
||||
return this.BOOK_BROWSER_PATHS.includes(path || '');
|
||||
}
|
||||
|
||||
private isBookDetailsRoute(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.routeConfig?.path === this.BOOK_DETAILS_PATH;
|
||||
}
|
||||
|
||||
shouldDetach(route: ActivatedRouteSnapshot): boolean {
|
||||
return this.isBookBrowserRoute(route);
|
||||
}
|
||||
|
||||
// Store the route component instance when detaching
|
||||
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
|
||||
if (handle) {
|
||||
// Save the handle if we are detaching this route
|
||||
this.storedRoutes.set(route.routeConfig?.path || '', handle);
|
||||
if (handle && this.isBookBrowserRoute(route)) {
|
||||
const key = this.getRouteKey(route);
|
||||
this.storedRoutes.set(key, handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should attach the route (reuse it) when navigating back to it
|
||||
shouldAttach(route: ActivatedRouteSnapshot): boolean {
|
||||
// Attach the route only if there's a stored instance for this route
|
||||
return !!this.storedRoutes.get(route.routeConfig?.path || '');
|
||||
if (!this.isBookBrowserRoute(route)) {
|
||||
return false;
|
||||
}
|
||||
const key = this.getRouteKey(route);
|
||||
return this.storedRoutes.has(key);
|
||||
}
|
||||
|
||||
// Retrieve the stored route component instance
|
||||
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
|
||||
return this.storedRoutes.get(route.routeConfig?.path || '') || null;
|
||||
const key = this.getRouteKey(route);
|
||||
const handle = this.storedRoutes.get(key) || null;
|
||||
|
||||
if (handle) {
|
||||
const savedPosition = this.scrollService.getPosition(key);
|
||||
if (savedPosition !== undefined) {
|
||||
setTimeout(() => {
|
||||
const scrollElement = document.querySelector('.virtual-scroller');
|
||||
if (scrollElement) {
|
||||
(scrollElement as HTMLElement).scrollTop = savedPosition;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Determine if the route should be reused based on its configuration
|
||||
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
|
||||
// Reuse the route if the path and parameters match
|
||||
return future.routeConfig === curr.routeConfig && future.params['id'] === curr.params['id'];
|
||||
return future.routeConfig === curr.routeConfig &&
|
||||
JSON.stringify(future.params) === JSON.stringify(curr.params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const OIDC_BYPASS_KEY = 'booklore-oidc-bypass';
|
||||
const OIDC_ERROR_COUNT_KEY = 'booklore-oidc-error-count';
|
||||
const MAX_OIDC_RETRIES = 3;
|
||||
const OIDC_TIMEOUT_MS = 5000;
|
||||
const SETTINGS_TIMEOUT_MS = 10000;
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return Promise.race([
|
||||
@@ -26,8 +27,23 @@ export function initializeAuthFactory() {
|
||||
const authInitService = inject(AuthInitializationService);
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!navigator.onLine) {
|
||||
console.warn('[Auth] App is offline, skipping auth initialization');
|
||||
authInitService.markAsInitialized();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsTimeout = setTimeout(() => {
|
||||
console.warn('[Auth] Public settings fetch timed out, falling back to local auth');
|
||||
sub.unsubscribe();
|
||||
authInitService.markAsInitialized();
|
||||
resolve();
|
||||
}, SETTINGS_TIMEOUT_MS);
|
||||
|
||||
const sub = appSettingsService.publicAppSettings$.subscribe(publicSettings => {
|
||||
if (publicSettings) {
|
||||
clearTimeout(settingsTimeout);
|
||||
const forceLocalOnly = new URLSearchParams(window.location.search).get('localOnly') === 'true';
|
||||
const oidcBypassed = localStorage.getItem(OIDC_BYPASS_KEY) === 'true';
|
||||
const errorCount = parseInt(localStorage.getItem(OIDC_ERROR_COUNT_KEY) || '0', 10);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BookBrowserScrollService {
|
||||
private scrollPositions = new Map<string, number>();
|
||||
|
||||
savePosition(key: string, position: number): void {
|
||||
this.scrollPositions.set(key, position);
|
||||
}
|
||||
|
||||
getPosition(key: string): number | undefined {
|
||||
return this.scrollPositions.get(key);
|
||||
}
|
||||
|
||||
clearPosition(key: string): void {
|
||||
this.scrollPositions.delete(key);
|
||||
}
|
||||
|
||||
createKey(path: string, params: Record<string, string>): string {
|
||||
const paramValues = Object.values(params).join('-');
|
||||
return paramValues ? `${path}:${paramValues}` : path;
|
||||
}
|
||||
}
|
||||
@@ -111,20 +111,43 @@
|
||||
<label for="collapse-series-checkbox" class="display-settings-label">Collapse series</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="display-settings-section">
|
||||
<label class="display-settings-label">Grid item size</label>
|
||||
<p-slider
|
||||
[(ngModel)]="coverScalePreferenceService.scaleFactor"
|
||||
[min]="0.5"
|
||||
[max]="1.5"
|
||||
[step]="0.01"
|
||||
[style]="{ width: '100%' }"
|
||||
(onChange)="updateScale()">
|
||||
</p-slider>
|
||||
<div class="scale-value-display">
|
||||
{{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x
|
||||
@if (isMobile) {
|
||||
<div class="display-settings-section">
|
||||
<label class="display-settings-label">Grid columns</label>
|
||||
<div class="column-options">
|
||||
<button
|
||||
type="button"
|
||||
class="column-option-btn"
|
||||
[class.active]="mobileColumnCount === 2"
|
||||
(click)="setMobileColumns(2)">2</button>
|
||||
<button
|
||||
type="button"
|
||||
class="column-option-btn"
|
||||
[class.active]="mobileColumnCount === 3"
|
||||
(click)="setMobileColumns(3)">3</button>
|
||||
<button
|
||||
type="button"
|
||||
class="column-option-btn"
|
||||
[class.active]="mobileColumnCount === 4"
|
||||
(click)="setMobileColumns(4)">4</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="display-settings-section">
|
||||
<label class="display-settings-label">Grid item size</label>
|
||||
<p-slider
|
||||
[(ngModel)]="coverScalePreferenceService.scaleFactor"
|
||||
[min]="0.5"
|
||||
[max]="1.5"
|
||||
[step]="0.01"
|
||||
[style]="{ width: '100%' }"
|
||||
(onChange)="updateScale()">
|
||||
</p-slider>
|
||||
<div class="scale-value-display">
|
||||
{{ coverScalePreferenceService.scaleFactor.toFixed(2) }}x
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="display-settings-section">
|
||||
<div class="display-settings-checkbox-row">
|
||||
<p-checkbox
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-title {
|
||||
@@ -51,11 +55,24 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.series-collapsed-info {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-menu-wrapper {
|
||||
@@ -130,6 +147,34 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.column-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.column-option-btn {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 6px;
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color-text);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort menu item
|
||||
.sort-menu-item {
|
||||
display: flex;
|
||||
@@ -285,6 +330,12 @@
|
||||
gap: 1.3rem;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
|
||||
// Mobile: fixed 2 or 3 columns based on phone width
|
||||
@media (max-width: 767px) {
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-scroller-item {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {AfterViewInit, Component, inject, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {AfterViewInit, Component, HostListener, inject, OnDestroy, OnInit, ViewChild} from '@angular/core';
|
||||
import {ActivatedRoute, NavigationStart, Router} from '@angular/router';
|
||||
import {ConfirmationService, MenuItem, MessageService, PrimeTemplate} from 'primeng/api';
|
||||
import {PageTitleService} from '../../../../shared/service/page-title.service';
|
||||
import {BookService} from '../../service/book.service';
|
||||
import {debounceTime, filter, map, switchMap} from 'rxjs/operators';
|
||||
import {debounceTime, filter, map, switchMap, takeUntil} from 'rxjs/operators';
|
||||
import {BehaviorSubject, combineLatest, finalize, Observable, of, Subject} from 'rxjs';
|
||||
import {DynamicDialogRef} from 'primeng/dynamicdialog';
|
||||
import {Library} from '../../model/library.model';
|
||||
@@ -16,7 +16,7 @@ import {BookTableComponent} from './book-table/book-table.component';
|
||||
import {animate, style, transition, trigger} from '@angular/animations';
|
||||
import {Button} from 'primeng/button';
|
||||
import {AsyncPipe, NgClass, NgStyle} from '@angular/common';
|
||||
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
import {VirtualScrollerComponent, VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
import {BookCardComponent} from './book-card/book-card.component';
|
||||
import {ProgressSpinner} from 'primeng/progressspinner';
|
||||
import {Menu} from 'primeng/menu';
|
||||
@@ -45,12 +45,14 @@ import {MetadataRefreshType} from '../../../metadata/model/request/metadata-refr
|
||||
import {TaskHelperService} from '../../../settings/task-management/task-helper.service';
|
||||
import {FilterLabelHelper} from './filter-label.helper';
|
||||
import {LoadingService} from '../../../../core/services/loading.service';
|
||||
import {LocalStorageService} from '../../../../shared/service/local-storage.service';
|
||||
import {BookNavigationService} from '../../service/book-navigation.service';
|
||||
import {BookCardOverlayPreferenceService} from './book-card-overlay-preference.service';
|
||||
import {BookSelectionService, CheckboxClickEvent} from './book-selection.service';
|
||||
import {BookBrowserQueryParamsService, VIEW_MODES} from './book-browser-query-params.service';
|
||||
import {BookBrowserEntityService} from './book-browser-entity.service';
|
||||
import {BookFilterOrchestrationService} from './book-filter-orchestration.service';
|
||||
import {BookBrowserScrollService} from './book-browser-scroll.service';
|
||||
|
||||
export enum EntityType {
|
||||
LIBRARY = 'Library',
|
||||
@@ -84,8 +86,8 @@ export enum EntityType {
|
||||
])
|
||||
]
|
||||
})
|
||||
export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
// Public injected services (used in template)
|
||||
export class BookBrowserComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
protected userService = inject(UserService);
|
||||
protected coverScalePreferenceService = inject(CoverScalePreferenceService);
|
||||
protected columnPreferenceService = inject(TableColumnPreferenceService);
|
||||
@@ -96,8 +98,8 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
protected bookCardOverlayPreferenceService = inject(BookCardOverlayPreferenceService);
|
||||
protected bookSelectionService = inject(BookSelectionService);
|
||||
|
||||
// Private injected services
|
||||
private activatedRoute = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private messageService = inject(MessageService);
|
||||
private bookService = inject(BookService);
|
||||
private dialogHelperService = inject(BookDialogHelperService);
|
||||
@@ -109,8 +111,9 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
private queryParamsService = inject(BookBrowserQueryParamsService);
|
||||
private entityService = inject(BookBrowserEntityService);
|
||||
private filterOrchestrationService = inject(BookFilterOrchestrationService);
|
||||
private localStorageService = inject(LocalStorageService);
|
||||
private scrollService = inject(BookBrowserScrollService);
|
||||
|
||||
// Observables
|
||||
bookState$: Observable<BookState> | undefined;
|
||||
entity$: Observable<Library | Shelf | MagicShelf | null> | undefined;
|
||||
entityType$: Observable<EntityType> | undefined;
|
||||
@@ -119,7 +122,6 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
selectedFilterMode = new BehaviorSubject<BookFilterMode>('and');
|
||||
protected resetFilterSubject = new Subject<void>();
|
||||
|
||||
// State
|
||||
parsedFilters: Record<string, string[]> = {};
|
||||
entity: Library | Shelf | MagicShelf | null = null;
|
||||
entityType: EntityType | undefined;
|
||||
@@ -136,8 +138,18 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
currentViewMode: string | undefined;
|
||||
lastAppliedSort: SortOption | null = null;
|
||||
showFilter = false;
|
||||
screenWidth = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
||||
mobileColumnCount = 3;
|
||||
|
||||
private readonly MOBILE_BREAKPOINT = 768;
|
||||
private readonly CARD_ASPECT_RATIO = 7 / 5;
|
||||
private readonly MOBILE_GAP = 8;
|
||||
private readonly MOBILE_PADDING = 48;
|
||||
private readonly MOBILE_TITLE_BAR_HEIGHT = 32;
|
||||
private readonly MOBILE_COLUMNS_STORAGE_KEY = 'mobileColumnsPreference';
|
||||
|
||||
private settingFiltersFromUrl = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
protected metadataMenuItems: MenuItem[] | undefined;
|
||||
protected bulkReadActionsMenuItems: MenuItem[] | undefined;
|
||||
|
||||
@@ -151,16 +163,43 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
bookTableComponent!: BookTableComponent;
|
||||
@ViewChild(BookFilterComponent, {static: false})
|
||||
bookFilterComponent!: BookFilterComponent;
|
||||
@ViewChild('scroll')
|
||||
virtualScroller: VirtualScrollerComponent | undefined;
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.screenWidth = window.innerWidth;
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.screenWidth < this.MOBILE_BREAKPOINT;
|
||||
}
|
||||
|
||||
get mobileCardSize(): { width: number; height: number } {
|
||||
const columns = this.mobileColumnCount;
|
||||
const totalGaps = (columns - 1) * this.MOBILE_GAP;
|
||||
const availableWidth = this.screenWidth - totalGaps - this.MOBILE_PADDING;
|
||||
const cardWidth = Math.floor(availableWidth / columns);
|
||||
const coverHeight = Math.floor(cardWidth * this.CARD_ASPECT_RATIO);
|
||||
const cardHeight = coverHeight + this.MOBILE_TITLE_BAR_HEIGHT;
|
||||
return {width: cardWidth, height: cardHeight};
|
||||
}
|
||||
|
||||
get selectedBooks(): Set<number> {
|
||||
return this.bookSelectionService.selectedBooks;
|
||||
}
|
||||
|
||||
get currentCardSize() {
|
||||
if (this.isMobile) {
|
||||
return this.mobileCardSize;
|
||||
}
|
||||
return this.coverScalePreferenceService.currentCardSize;
|
||||
}
|
||||
|
||||
get gridColumnMinWidth(): string {
|
||||
if (this.isMobile) {
|
||||
return `${this.mobileCardSize.width}px`;
|
||||
}
|
||||
return this.coverScalePreferenceService.gridColumnMinWidth;
|
||||
}
|
||||
|
||||
@@ -217,12 +256,14 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
ngOnInit(): void {
|
||||
this.pageTitle.setPageTitle('');
|
||||
this.coverScalePreferenceService.scaleChange$.pipe(debounceTime(1000)).subscribe();
|
||||
this.loadMobileColumnsPreference();
|
||||
|
||||
this.initializeEntityRouting();
|
||||
this.setupRouteChangeHandlers();
|
||||
this.setupUserStateSubscription();
|
||||
this.setupQueryParamSubscription();
|
||||
this.setupSearchTermSubscription();
|
||||
this.setupScrollPositionTracking();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -233,6 +274,33 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private getScrollPositionKey(): string {
|
||||
const path = this.activatedRoute.snapshot.routeConfig?.path ?? '';
|
||||
return this.scrollService.createKey(path, this.activatedRoute.snapshot.params);
|
||||
}
|
||||
|
||||
private setupScrollPositionTracking(): void {
|
||||
this.router.events.pipe(
|
||||
filter(event => event instanceof NavigationStart),
|
||||
takeUntil(this.destroy$)
|
||||
).subscribe(() => {
|
||||
this.saveScrollPosition();
|
||||
});
|
||||
}
|
||||
|
||||
private saveScrollPosition(): void {
|
||||
if (this.virtualScroller?.viewPortInfo) {
|
||||
const key = this.getScrollPositionKey();
|
||||
const position = this.virtualScroller.viewPortInfo.scrollStartPosition ?? 0;
|
||||
this.scrollService.savePosition(key, position);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeEntityRouting(): void {
|
||||
const currentPath = this.activatedRoute.snapshot.routeConfig?.path;
|
||||
|
||||
@@ -312,7 +380,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
user.user?.userSettings?.filterMode ?? 'and'
|
||||
);
|
||||
|
||||
// Apply filter mode
|
||||
|
||||
if (parseResult.filterMode !== this.selectedFilterMode.getValue()) {
|
||||
this.selectedFilterMode.next(parseResult.filterMode);
|
||||
if (this.bookFilterComponent) {
|
||||
@@ -324,7 +392,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
this.showFilter = value;
|
||||
});
|
||||
|
||||
// Apply filters
|
||||
|
||||
this.currentFilterLabel = 'All Books';
|
||||
const filterParams = queryParamMap.get('filter');
|
||||
|
||||
@@ -350,13 +418,13 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
|
||||
this.parsedFilters = parseResult.filters;
|
||||
|
||||
// Apply preferences
|
||||
|
||||
this.entityViewPreferences = user.user?.userSettings?.entityViewPreferences;
|
||||
this.coverScalePreferenceService.initScaleValue(this.coverScalePreferenceService.scaleFactor);
|
||||
this.columnPreferenceService.initPreferences(user.user?.userSettings?.tableColumnPreference);
|
||||
this.visibleColumns = this.columnPreferenceService.visibleColumns;
|
||||
|
||||
// Apply sort
|
||||
|
||||
this.bookSorter.selectedSort = parseResult.sortOption;
|
||||
this.currentViewMode = parseResult.viewMode;
|
||||
this.bookSorter.updateSortOptions();
|
||||
@@ -367,7 +435,7 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
this.applySortOption(this.bookSorter.selectedSort);
|
||||
}
|
||||
|
||||
// Sync query params
|
||||
|
||||
this.queryParamsService.syncQueryParams(
|
||||
this.currentViewMode!,
|
||||
this.bookSorter.selectedSort,
|
||||
@@ -454,9 +522,15 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
|
||||
confirmDeleteBooks(): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?`,
|
||||
message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?\n\nThis will permanently remove the book files from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
const count = this.selectedBooks.size;
|
||||
const loader = this.loadingService.show(`Deleting ${count} book(s)...`);
|
||||
@@ -698,4 +772,16 @@ export class BookBrowserComponent implements OnInit, AfterViewInit {
|
||||
this.bookSorter.selectedSort!
|
||||
);
|
||||
}
|
||||
|
||||
setMobileColumns(columns: number): void {
|
||||
this.mobileColumnCount = columns;
|
||||
this.localStorageService.set(this.MOBILE_COLUMNS_STORAGE_KEY, columns);
|
||||
}
|
||||
|
||||
private loadMobileColumnsPreference(): void {
|
||||
const saved = this.localStorageService.get<number>(this.MOBILE_COLUMNS_STORAGE_KEY);
|
||||
if (saved !== null && [2, 3, 4].includes(saved)) {
|
||||
this.mobileColumnCount = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: 0.75rem;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.info-btn,
|
||||
@@ -79,6 +84,31 @@
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
z-index: 2;
|
||||
will-change: opacity, visibility;
|
||||
|
||||
::ng-deep .p-button {
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
width: clamp(1.5rem, 8vw, 2.5rem);
|
||||
height: clamp(1.5rem, 8vw, 2.5rem);
|
||||
padding: 0;
|
||||
|
||||
.pi {
|
||||
font-size: clamp(0.65rem, 3.5vw, 1rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
@@ -109,6 +139,11 @@
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
z-index: 2;
|
||||
will-change: opacity, visibility;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-checkbox .p-checkbox {
|
||||
@@ -203,6 +238,12 @@
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
z-index: 4;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.book-type-pill-overlay,
|
||||
@@ -210,6 +251,11 @@
|
||||
.series-items-count-overlay {
|
||||
position: static;
|
||||
margin: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
font-size: 0.6rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.book-type-epub {
|
||||
|
||||
@@ -303,12 +303,15 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
icon: 'pi pi-trash',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete "${this.book.metadata?.title}"?`,
|
||||
message: `Are you sure you want to delete "${this.book.metadata?.title}"?\n\nThis will permanently remove the book file from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
this.bookService.deleteBooks(new Set([this.book.id])).subscribe();
|
||||
}
|
||||
@@ -622,12 +625,15 @@ export class BookCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
icon: 'pi pi-book',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete "${this.book.metadata?.title}"?`,
|
||||
message: `Are you sure you want to delete "${this.book.metadata?.title}"?\n\nThis will permanently remove the book file from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
this.bookService.deleteBooks(new Set([this.book.id])).subscribe();
|
||||
}
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
@@ -370,9 +370,15 @@ export class SeriesPageComponent implements OnDestroy {
|
||||
|
||||
confirmDeleteBooks(): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?`,
|
||||
message: `Are you sure you want to delete ${this.selectedBooks.size} book(s)?\n\nThis will permanently remove the book files from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
const count = this.selectedBooks.size;
|
||||
const loader = this.loadingService.show(`Deleting ${count} book(s)...`);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {CheckboxModule} from 'primeng/checkbox';
|
||||
import {InputTextModule} from 'primeng/inputtext';
|
||||
import {SelectModule} from 'primeng/select';
|
||||
import {InputNumberModule} from 'primeng/inputnumber';
|
||||
import {SortDirection} from "../../../book/model/sort.model";
|
||||
import {DashboardConfig, ScrollerConfig, ScrollerType} from '../../models/dashboard-config.model';
|
||||
import {DashboardConfigService} from '../../services/dashboard-config.service';
|
||||
import {MagicShelfService} from '../../../magic-shelf/service/magic-shelf.service';
|
||||
@@ -57,10 +56,11 @@ export class DashboardSettingsComponent implements OnInit {
|
||||
sortFieldOptions = [
|
||||
{label: 'Title', value: 'title'},
|
||||
{label: 'Title + Series', value: 'titleSeries'},
|
||||
{label: 'File Name', field: 'fileName'},
|
||||
{label: 'File Name', value: 'fileName'},
|
||||
{label: 'Date Added', value: 'addedOn'},
|
||||
{label: 'Author', value: 'author'},
|
||||
{label: 'Author + Series', field: 'authorSeries'},
|
||||
{label: 'Author (Surname)', value: 'authorSurnameVorname'},
|
||||
{label: 'Author + Series', value: 'authorSeries'},
|
||||
{label: 'Personal Rating', value: 'personalRating'},
|
||||
{label: 'Publisher', value: 'publisher'},
|
||||
{label: 'Published Date', value: 'publishedDate'},
|
||||
|
||||
@@ -235,12 +235,15 @@ export class MetadataViewerComponent implements OnInit, OnChanges {
|
||||
icon: 'pi pi-trash',
|
||||
command: () => {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete "${book.metadata?.title}"?`,
|
||||
message: `Are you sure you want to delete "${book.metadata?.title}"?\n\nThis will permanently remove the book file from your filesystem.\n\nThis action cannot be undone.`,
|
||||
header: 'Confirm Deletion',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptIcon: 'pi pi-trash',
|
||||
rejectIcon: 'pi pi-times',
|
||||
acceptLabel: 'Delete',
|
||||
rejectLabel: 'Cancel',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
rejectButtonStyleClass: 'p-button-outlined',
|
||||
accept: () => {
|
||||
this.bookService.deleteBooks(new Set([book.id])).subscribe({
|
||||
next: () => {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
overscroll-behavior: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
@@ -267,6 +271,8 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
align-items: flex-start;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
.infinite-scroll-wrapper {
|
||||
display: flex;
|
||||
|
||||
@@ -92,10 +92,9 @@
|
||||
}
|
||||
|
||||
.book-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100% - 240px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -105,7 +104,6 @@
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +122,6 @@
|
||||
|
||||
.book-title {
|
||||
font-size: 12px;
|
||||
max-width: calc(100% - 160px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +75,9 @@ export const themes: Theme[] = [
|
||||
light: {fg: '#4a148c', bg: '#f3e5f5', link: '#7b1fa2'},
|
||||
dark: {fg: '#c7b6dd', bg: '#3a3150', link: '#b39ddb'},
|
||||
},
|
||||
{
|
||||
name: 'amoled', label: 'AMOLED',
|
||||
light: {fg: '#000000', bg: '#ffffff', link: '#0066cc'},
|
||||
dark: {fg: '#ffffff', bg: '#000000', link: '#77bbee'},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
<div class="settings-card">
|
||||
@if (libraries$ | async; as libraries) {
|
||||
<p-accordion>
|
||||
<p-accordion [value]="activePanel" (valueChange)="onPanelChange($event)">
|
||||
@for (library of libraries; track trackByLibrary($index, library); let i = $index) {
|
||||
<p-accordion-panel [value]="i">
|
||||
<p-accordion-header>
|
||||
@@ -75,11 +75,13 @@
|
||||
</p-accordion-header>
|
||||
<p-accordion-content>
|
||||
<div class="accordion-content-wrapper">
|
||||
<app-metadata-advanced-fetch-options
|
||||
[currentMetadataOptions]="getLibraryOptions(library.id!)"
|
||||
[submitButtonLabel]="'Save ' + library.name + ' Settings'"
|
||||
(metadataOptionsSubmitted)="onLibraryMetadataOptionsSubmitted(library.id!, $event)">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
@if (activePanel === i) {
|
||||
<app-metadata-advanced-fetch-options
|
||||
[currentMetadataOptions]="getLibraryOptions(library.id!)"
|
||||
[submitButtonLabel]="'Save ' + library.name + ' Settings'"
|
||||
(metadataOptionsSubmitted)="onLibraryMetadataOptionsSubmitted(library.id!, $event)">
|
||||
</app-metadata-advanced-fetch-options>
|
||||
}
|
||||
</div>
|
||||
</p-accordion-content>
|
||||
</p-accordion-panel>
|
||||
|
||||
@@ -32,6 +32,7 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
|
||||
defaultMetadataOptions: MetadataRefreshOptions = this.getDefaultMetadataOptions();
|
||||
libraryMetadataOptions: Record<number, MetadataRefreshOptions> = {};
|
||||
activePanel: number | null = null;
|
||||
|
||||
ngOnInit() {
|
||||
this.appSettingsService.appSettings$.subscribe(appSettings => {
|
||||
@@ -54,6 +55,10 @@ export class LibraryMetadataSettingsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
onPanelChange(event: unknown) {
|
||||
this.activePanel = typeof event === 'number' ? event : null;
|
||||
}
|
||||
|
||||
onDefaultMetadataOptionsSubmitted(options: MetadataRefreshOptions) {
|
||||
this.defaultMetadataOptions = options;
|
||||
this.saveDefaultMetadataOptions(options);
|
||||
|
||||
@@ -36,8 +36,10 @@ export class ViewPreferencesComponent implements OnInit, OnDestroy {
|
||||
{label: 'Title + Series', field: 'titleSeries'},
|
||||
{label: 'File Name', field: 'fileName'},
|
||||
{label: 'Author', field: 'author'},
|
||||
{label: 'Author (Surname)', field: 'authorSurnameVorname'},
|
||||
{label: 'Author + Series', field: 'authorSeries'},
|
||||
{label: 'Last Read', field: 'lastReadTime'},
|
||||
{label: 'Personal Rating', field: 'personalRating'},
|
||||
{label: 'Added On', field: 'addedOn'},
|
||||
{label: 'File Size', field: 'fileSizeKb'},
|
||||
{label: 'Locked', field: 'locked'},
|
||||
|
||||
@@ -3,7 +3,7 @@ import {CommonModule} from '@angular/common';
|
||||
import {BaseChartDirective} from 'ng2-charts';
|
||||
import {BehaviorSubject, EMPTY, Observable, Subject} from 'rxjs';
|
||||
import {catchError, filter, first, switchMap, takeUntil} from 'rxjs/operators';
|
||||
import {ChartConfiguration, ChartData, TooltipItem} from 'chart.js';
|
||||
import {Chart, ChartConfiguration, ChartData} from 'chart.js';
|
||||
import {LibraryFilterService} from '../../service/library-filter.service';
|
||||
import {BookService} from '../../../../../book/service/book.service';
|
||||
import {BookState} from '../../../../../book/model/state/book-state.model';
|
||||
@@ -90,6 +90,7 @@ export class AuthorUniverseChartComponent implements OnInit, OnDestroy {
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
document.getElementById('author-chart-tooltip')?.remove();
|
||||
}
|
||||
|
||||
private initChartOptions(): void {
|
||||
@@ -170,45 +171,8 @@ export class AuthorUniverseChartComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
titleColor: '#ffffff',
|
||||
bodyColor: '#ffffff',
|
||||
borderColor: '#8b5cf6',
|
||||
borderWidth: 2,
|
||||
cornerRadius: 8,
|
||||
padding: 16,
|
||||
titleFont: {size: 14, weight: 'bold'},
|
||||
bodyFont: {size: 12},
|
||||
callbacks: {
|
||||
title: (context: TooltipItem<'bubble'>[]) => {
|
||||
const raw = context[0].raw as BubbleDataPoint;
|
||||
return raw.authorStats.name;
|
||||
},
|
||||
label: (context: TooltipItem<'bubble'>) => {
|
||||
const raw = context.raw as BubbleDataPoint;
|
||||
const stats = raw.authorStats;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Books: ${stats.bookCount}`);
|
||||
lines.push(`Total Pages: ${stats.totalPages.toLocaleString()}`);
|
||||
|
||||
if (stats.avgRating > 0) {
|
||||
lines.push(`Avg Rating: ${stats.avgRating.toFixed(2)} ★`);
|
||||
} else {
|
||||
lines.push(`Avg Rating: No ratings`);
|
||||
}
|
||||
|
||||
lines.push(`Read: ${stats.readCount}/${stats.bookCount} (${Math.round(stats.completionRate)}%)`);
|
||||
|
||||
if (stats.categories.length > 0) {
|
||||
const topCategories = stats.categories.slice(0, 3).join(', ');
|
||||
lines.push(`Genres: ${topCategories}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
enabled: false,
|
||||
external: (context) => this.handleExternalTooltip(context)
|
||||
},
|
||||
datalabels: {
|
||||
display: false
|
||||
@@ -552,6 +516,69 @@ export class AuthorUniverseChartComponent implements OnInit, OnDestroy {
|
||||
return insights;
|
||||
}
|
||||
|
||||
private handleExternalTooltip(context: { chart: Chart; tooltip: any }): void {
|
||||
const {chart, tooltip} = context;
|
||||
let tooltipEl = document.getElementById('author-chart-tooltip');
|
||||
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.id = 'author-chart-tooltip';
|
||||
Object.assign(tooltipEl.style, {
|
||||
position: 'fixed',
|
||||
zIndex: '9999',
|
||||
background: 'rgba(0, 0, 0, 0.95)',
|
||||
border: '2px solid #8b5cf6',
|
||||
borderRadius: '8px',
|
||||
padding: '12px 16px',
|
||||
pointerEvents: 'none',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.15s ease',
|
||||
transform: 'translate(-50%, calc(-100% - 12px))',
|
||||
maxWidth: '280px',
|
||||
whiteSpace: 'nowrap',
|
||||
fontFamily: "'Inter', sans-serif"
|
||||
});
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Only use the first (nearest) data point
|
||||
const dataPoint = tooltip.dataPoints?.[0];
|
||||
if (!dataPoint) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = dataPoint.raw as BubbleDataPoint;
|
||||
const stats = raw.authorStats;
|
||||
|
||||
const ratingText = stats.avgRating > 0
|
||||
? `${stats.avgRating.toFixed(2)} ★`
|
||||
: 'No ratings';
|
||||
|
||||
const categoriesHtml = stats.categories.length > 0
|
||||
? `<div style="color:rgba(255,255,255,0.9);font-size:12px;line-height:1.6">Genres: ${stats.categories.slice(0, 3).join(', ')}</div>`
|
||||
: '';
|
||||
|
||||
tooltipEl.innerHTML = `
|
||||
<div style="color:#fff;font-size:14px;font-weight:700;margin-bottom:6px">${stats.name}</div>
|
||||
<div style="color:rgba(255,255,255,0.9);font-size:12px;line-height:1.6">Books: ${stats.bookCount}</div>
|
||||
<div style="color:rgba(255,255,255,0.9);font-size:12px;line-height:1.6">Total Pages: ${stats.totalPages.toLocaleString()}</div>
|
||||
<div style="color:rgba(255,255,255,0.9);font-size:12px;line-height:1.6">Avg Rating: ${ratingText}</div>
|
||||
<div style="color:rgba(255,255,255,0.9);font-size:12px;line-height:1.6">Read: ${stats.readCount}/${stats.bookCount} (${Math.round(stats.completionRate)}%)</div>
|
||||
${categoriesHtml}
|
||||
`;
|
||||
|
||||
const canvasRect = chart.canvas.getBoundingClientRect();
|
||||
tooltipEl.style.opacity = '1';
|
||||
tooltipEl.style.left = (canvasRect.left + tooltip.caretX) + 'px';
|
||||
tooltipEl.style.top = (canvasRect.top + tooltip.caretY) + 'px';
|
||||
}
|
||||
|
||||
private hexToRgba(hex: string, alpha: number): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.topbar-logo {
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 6rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
BIN
booklore-ui/src/assets/images/topbar.png
Normal file
BIN
booklore-ui/src/assets/images/topbar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -115,3 +115,18 @@
|
||||
margin: 0.25rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Confirmation Dialog Styles
|
||||
// =============================================================================
|
||||
|
||||
.p-confirmdialog {
|
||||
max-width: 500px;
|
||||
width: 90vw;
|
||||
|
||||
.p-confirmdialog-message {
|
||||
white-space: pre-line;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ body {
|
||||
min-height: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--glow-image: url(https://github.com/booklore-app/booklore-docs/blob/master/static/img/cdn/bg.png?raw=true), radial-gradient(50% 50% at center -25px, var(--p-primary-color) 0%, #000000 100%);
|
||||
--glow-image: url(../../../images/topbar.png), radial-gradient(50% 50% at center -25px, var(--p-primary-color) 0%, #000000 100%);
|
||||
--glow-blend: hard-light, color-dodge;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user