Merge pull request #2458 from booklore-app/develop

Merge develop into master for release
This commit is contained in:
ACX
2026-01-24 09:37:07 -07:00
committed by GitHub
30 changed files with 579 additions and 170 deletions

View File

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

View File

@@ -8,6 +8,7 @@
"resources": {
"files": [
"/favicon.ico",
"/assets/favicon.svg",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)...`);

View File

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

View File

@@ -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: () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
.topbar-logo {
padding-left: 1.25rem;
padding-right: 6rem;
display: flex;
gap: 0.5rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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