import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core'; import {NavigationEnd, Router} from '@angular/router'; import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators'; import {ImageService} from 'src/app/_services/image.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; import {Breakpoint, UtilityService} from '../../../shared/_services/utility.service'; import {Library, LibraryType} from '../../../_models/library/library'; import {AccountService} from '../../../_services/account.service'; import {Action, ActionFactoryService, ActionItem} from '../../../_services/action-factory.service'; import {ActionService} from '../../../_services/action.service'; import {NavService} from '../../../_services/nav.service'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {BehaviorSubject, merge, Observable, of, ReplaySubject, startWith, switchMap} from "rxjs"; import {AsyncPipe, NgClass} from "@angular/common"; import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component"; import {FilterPipe} from "../../../_pipes/filter.pipe"; import {FormsModule} from "@angular/forms"; import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {SideNavStream} from "../../../_models/sidenav/sidenav-stream"; import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum"; import {WikiLink} from "../../../_models/wiki"; import {SettingsTabId} from "../../preference-nav/preference-nav.component"; import {LicenseService} from "../../../_services/license.service"; import {CdkDrag, CdkDragDrop, CdkDropList} from "@angular/cdk/drag-drop"; import {ToastrService} from "ngx-toastr"; import {ReadingProfileService} from "../../../_services/reading-profile.service"; @Component({ selector: 'app-side-nav', imports: [SideNavItemComponent, CardActionablesComponent, FilterPipe, FormsModule, TranslocoDirective, NgbTooltip, NgClass, AsyncPipe, CdkDropList, CdkDrag], templateUrl: './side-nav.component.html', styleUrls: ['./side-nav.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class SideNavComponent implements OnInit { protected readonly WikiLink = WikiLink; protected readonly ItemLimit = 13; protected readonly SideNavStreamType = SideNavStreamType; protected readonly SettingsTabId = SettingsTabId; protected readonly Breakpoint = Breakpoint; private readonly router = inject(Router); protected readonly utilityService = inject(UtilityService); private readonly messageHub = inject(MessageHubService); private readonly actionService = inject(ActionService); protected readonly navService = inject(NavService); private readonly cdRef = inject(ChangeDetectorRef); private readonly imageService = inject(ImageService); protected readonly accountService = inject(AccountService); protected readonly licenseService = inject(LicenseService); private readonly destroyRef = inject(DestroyRef); private readonly actionFactoryService = inject(ActionFactoryService); private readonly toastr = inject(ToastrService); private readonly readingProfilesService = inject(ReadingProfileService); private readonly translocoService = inject(TranslocoService); cachedData: SideNavStream[] | null = null; actions: ActionItem[] = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); homeActions: ActionItem[] = this.actionFactoryService.getSideNavHomeActions(this.handleHomeAction.bind(this)); filterQuery: string = ''; filterLibrary = (stream: SideNavStream) => { return stream.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0; } showAll: boolean = false; editMode: boolean = false; totalSize = 0; isReadOnly = false; private showAllSubject = new BehaviorSubject(false); showAll$ = this.showAllSubject.asObservable(); private loadDataSubject = new ReplaySubject(); loadData$ = this.loadDataSubject.asObservable(); loadDataOnInit$: Observable = this.loadData$.pipe( switchMap(() => { if (this.cachedData != null) { return of(this.cachedData); } return this.navService.getSideNavStreams().pipe( map(data => { this.cachedData = data; // Cache the data after initial load return data; }) ); }) ); navStreams$: Observable = merge( this.showAll$.pipe( startWith(false), distinctUntilChanged(), tap(showAll => this.showAll = showAll), switchMap(showAll => showAll ? this.loadDataOnInit$.pipe( tap(d => this.totalSize = d.length), ) : this.loadDataOnInit$.pipe( tap(d => this.totalSize = d.length), map(d => d.slice(0, this.ItemLimit)) ) ), takeUntilDestroyed(this.destroyRef), ), this.messageHub.messages$.pipe( filter(event => event.event === EVENTS.LibraryModified || event.event === EVENTS.SideNavUpdate), tap(() => { this.cachedData = null; // Reset cached data to null to get latest }), switchMap(() => { if (this.showAll) return this.loadDataOnInit$; else return this.loadDataOnInit$.pipe(map(d => d.slice(0, this.ItemLimit))) }), // Reload data when events occur takeUntilDestroyed(this.destroyRef), ) ).pipe( startWith(null), filter(data => data !== null), takeUntilDestroyed(this.destroyRef), ); collapseSideNavOnMobileNav$ = this.router.events.pipe( filter(event => event instanceof NavigationEnd), takeUntilDestroyed(this.destroyRef), map(evt => evt as NavigationEnd), filter(() => this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet), switchMap(() => this.navService.sideNavCollapsed$), take(1), filter(collapsed => !collapsed) ); constructor() { // Ensure that on mobile, we are collapsed by default if (this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) { this.navService.collapseSideNav(true); } this.collapseSideNavOnMobileNav$.subscribe(() => { this.navService.collapseSideNav(false); this.cdRef.markForCheck(); }); } ngOnInit(): void { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (!user) return; this.isReadOnly = this.accountService.hasReadOnlyRole(user!); this.cdRef.markForCheck(); this.loadDataSubject.next(); }); } async handleAction(action: ActionItem, library: Library) { const lib = library; switch (action.action) { case(Action.Scan): await this.actionService.scanLibrary(lib); break; case(Action.RefreshMetadata): await this.actionService.refreshLibraryMetadata(lib); break; case(Action.GenerateColorScape): await this.actionService.refreshLibraryMetadata(lib, undefined, false); break; case (Action.AnalyzeFiles): await this.actionService.analyzeFiles(lib); break; case (Action.Delete): await this.actionService.deleteLibrary(lib); break; case (Action.Edit): this.actionService.editLibrary(lib, () => window.scrollTo(0, 0)); break; case (Action.SetReadingProfile): this.actionService.setReadingProfileForLibrary(lib); break; case (Action.ClearReadingProfile): this.readingProfilesService.clearLibraryProfiles(lib.id).subscribe(() => { this.toastr.success(this.translocoService.translate('actionable.cleared-profile')); }); break; default: break; } } async handleHomeAction(action: ActionItem) { switch (action.action) { case Action.Edit: this.showMore(true); break; default: break; } } performAction(action: ActionItem, library: Library) { if (typeof action.callback === 'function') { console.log('library: ', library) action.callback(action, library); } } performHomeAction(action: ActionItem) { if (typeof action.callback === 'function') { action.callback(action) } } getLibraryTypeIcon(format: LibraryType) { switch (format) { case LibraryType.Book: case LibraryType.LightNovel: return 'fa-book'; case LibraryType.Comic: case LibraryType.ComicVine: case LibraryType.Manga: return 'fa-book-open'; case LibraryType.Images: return 'fa-images'; } } getLibraryImage(library: Library) { if (library.coverImage) return this.imageService.getLibraryCoverImage(library.id); return null; } toggleNavBar() { this.navService.toggleSideNav(); } showMore(edit: boolean = false) { this.showAllSubject.next(true); this.editMode = edit; this.cdRef.markForCheck(); } showLess() { this.filterQuery = ''; this.showAllSubject.next(false); this.editMode = false; this.cdRef.markForCheck(); } async reorderDrop($event: CdkDragDrop) { // Don't allow dropping on non SideNav items const fixedSideNavItems = 3; if ($event.currentIndex < fixedSideNavItems) { return; } const stream = $event.item.data; // Offset the home, back, and customize button this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({ next: () => { this.showAllSubject.next(this.showAll); this.cdRef.markForCheck(); }, error: err => { console.error(err); this.toastr.error(translate('errors.generic')); } }); } }