Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666)

This commit is contained in:
Joe Milazzo 2022-11-22 07:41:30 -06:00 committed by GitHub
parent e2470cba88
commit ab9021cb32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
198 changed files with 263 additions and 270 deletions

View file

@ -0,0 +1,173 @@
<ng-container *ngIf="isAdmin$ | async">
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngIf="infos$ | async as infos">
<button type="button" class="btn btn-icon" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0, 'colored-error': errors.length > 0,
'colored-info': infos.length > 0 && errors.length === 0}"
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
</button>
</ng-container>
</ng-container>
</ng-container>
<ng-template #popContent>
<ul class="list-group list-group-flush dark-menu">
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngIf="infos$ | async as infos">
<li class="list-group-item dark-menu-item clickable" *ngIf="errors.length > 0 || infos.length > 0" (click)="clearAllErrorOrInfos()">
Dismiss All
</li>
</ng-container>
</ng-container>
<ng-container *ngIf="debugMode">
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Title goes here</div>
<div class="accent-text mb-1">Subtitle goes here</div>
</li>
<li class="list-group-item dark-menu-item">
<div>
<div class="h6 mb-1">Scanning Books</div>
<div class="accent-text mb-1">E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(0.1)}}%</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': 0.1 * 100 + '%'}" [attr.aria-valuenow]="0.1 * 100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</div>
</li>
<li class="list-group-item dark-menu-item error">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>There was some library scan error</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item info">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>Scan didn't run becasuse nothing to do</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" ></button>
</li>
<li class="list-group-item dark-menu-item">
<div class="d-inline-flex">
<span class="download">
<app-circular-loader [currentValue]="25" fontSize="16px" [showIcon]="true" width="25px" height="unset" [center]="false"></app-circular-loader>
<span class="visually-hidden" role="status">
10% downloaded
</span>
</span>
<span class="h6 mb-1">Downloading {{'series' | sentenceCase}}</span>
</div>
<div class="accent-text">PDFs</div>
</li>
</ng-container>
<!-- Progress Events-->
<ng-container *ngIf="progressEvents$ | async as progressUpdates">
<ng-container *ngFor="let message of progressUpdates">
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
<div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''" [title]="message.subTitle">{{message.subTitle}}</div>
<div class="progress-container row g-0 align-items-center">
<div class="progress" style="height: 5px;" *ngIf="message.progress === 'indeterminate'">
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 100%" [attr.aria-valuenow]="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
<ng-template #progressEvent>
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''">{{message.subTitle}}</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{prettyPrintProgress(message.body.progress) + '%'}}</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': message.body.progress * 100 + '%'}" [attr.aria-valuenow]="message.body.progress * 100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
</ng-template>
</ng-container>
</ng-container>
<!-- Single updates (Informational/Update available)-->
<ng-container *ngIf="singleUpdates$ | async as singleUpdates">
<ng-container *ngFor="let singleUpdate of singleUpdates">
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name === EVENTS.UpdateAvailable" (click)="handleUpdateAvailableClick(singleUpdate)">
<i class="fa fa-chevron-circle-up" aria-hidden="true"></i>&nbsp;Update available
</li>
<li class="list-group-item dark-menu-item update-available" *ngIf="singleUpdate.name !== EVENTS.UpdateAvailable">
<div>{{singleUpdate.title}}</div>
<div class="accent-text" *ngIf="singleUpdate.subTitle !== ''">{{singleUpdate.subTitle}}</div>
</li>
</ng-container>
</ng-container>
<!-- Active Downloads by the user-->
<ng-container *ngIf="downloadService.activeDownloads$ | async as activeDownloads">
<ng-container *ngFor="let download of activeDownloads">
<li class="list-group-item dark-menu-item">
<div class="h6 mb-1">Downloading {{download.entityType | sentenceCase}}</div>
<div class="accent-text mb-1" *ngIf="download.subTitle !== ''">{{download.subTitle}}</div>
<div class="progress-container row g-0 align-items-center">
<div class="col-2">{{download.progress}}%</div>
<div class="col-10 progress" style="height: 5px;">
<div class="progress-bar" role="progressbar" [ngStyle]="{'width': download.progress + '%'}" [attr.aria-valuenow]="download.progress" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
</li>
</ng-container>
</ng-container>
<!-- Errors -->
<ng-container *ngIf="errors$ | async as errors">
<ng-container *ngFor="let error of errors">
<li class="list-group-item dark-menu-item error" role="alert" (click)="seeMore(error)">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-triangle-exclamation me-2"></i>{{error.title}}</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" (click)="removeErrorOrInfo(error, $event)"></button>
</li>
</ng-container>
</ng-container>
<!-- Infos -->
<ng-container *ngIf="infos$ | async as infos">
<ng-container *ngFor="let info of infos">
<li class="list-group-item dark-menu-item info" role="alert" (click)="seeMore(info)">
<div>
<div class="h6 mb-1"><i class="fa-solid fa-circle-info me-2"></i>{{info.title}}</div>
<div class="accent-text mb-1">Click for more information</div>
</div>
<button type="button" class="btn-close float-end" aria-label="close" (click)="removeErrorOrInfo(info, $event)"></button>
</li>
</ng-container>
</ng-container>
<!-- Online Users -->
<ng-container *ngIf="messageHub.onlineUsers$ | async as onlineUsers">
<li class="list-group-item dark-menu-item" *ngIf="onlineUsers.length > 1">
<div>{{onlineUsers.length}} Users online</div>
</li>
<li class="list-group-item dark-menu-item" *ngIf="activeEvents === 0 && onlineUsers.length <= 1">Not much going on here</li>
<li class="list-group-item dark-menu-item" *ngIf="debugMode">Active Events: {{activeEvents}}</li>
</ng-container>
</ul>
</ng-template>
</ng-container>

View file

@ -0,0 +1,122 @@
.dark-menu {
background-color: var(--navbar-bg-color);
border-color: var(--event-widget-border-color); // rgba(1, 4, 9, 0.5);
}
.dark-menu-item {
color: var(--event-widget-text-color);
background-color: var(--event-widget-item-bg-color); // rgb(1, 4, 9)
border-color: var(--event-widget-item-border-color); // rgba(53, 53, 53, 0.5)
}
// Popovers need to be their own component
::ng-deep .bs-popover-bottom > .popover-arrow::after, .bs-popover-bottom > .popover-arrow::before {
border-bottom-color: transparent;
}
::ng-deep .nav-events {
.popover-body {
min-width: 250px;
max-width: 250px;
padding: 0px;
box-shadow: 0px 0px 12px rgb(0 0 0 / 75%);
max-height: calc(100vh - 60px);
overflow-y: auto;
}
.popover {
min-width: 300px;
}
}
.progress-container {
width: 100%;
}
.progress {
padding: 0;
}
.accent-text {
width: 100%;
text-overflow: ellipsis;
overflow:hidden;
white-space:nowrap;
}
.btn:focus, .btn:hover {
box-shadow: 0 0 0 0.1rem var(--navbar-btn-hover-outline-color);
}
.small-spinner {
width: 1rem;
height: 1rem;
}
.btn-icon {
color: white;
}
.colored {
background-color: var(--primary-color);
border-radius: 60px;
}
.colored-error {
background-color: var(--error-color) !important;
border-radius: 60px;
}
.colored-info {
background-color: var(--event-widget-info-bg-color) !important;
border-radius: 60px;
}
.update-available {
cursor: pointer;
i.fa {
color: var(--primary-color) !important;
}
color: var(--primary-color);
}
.error {
cursor: pointer;
position: relative;
.h6 {
color: var(--error-color);
}
i.fa {
color: var(--primary-color) !important;
}
.btn-close {
top: 5px;
right: 10px;
font-size: 11px;
position: absolute;
}
}
.info {
cursor: pointer;
position: relative;
.h6 {
color: var(--event-widget-info-bg-color);
}
i.fa {
color: var(--primary-color) !important;
}
.btn-close {
top: 10px;
right: 10px;
font-size: 11px;
position: absolute;
}
}

View file

@ -0,0 +1,203 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
import { map, shareReplay, takeUntil } from 'rxjs/operators';
import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { ErrorEvent } from 'src/app/_models/events/error-event';
import { InfoEvent } from 'src/app/_models/events/info-event';
import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event';
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service';
@Component({
selector: 'app-nav-events-toggle',
templateUrl: './events-widget.component.html',
styleUrls: ['./events-widget.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EventsWidgetComponent implements OnInit, OnDestroy {
@Input() user!: User;
isAdmin$: Observable<boolean> = of(false);
private readonly onDestroy = new Subject<void>();
/**
* Progress events (Event Type: 'started', 'ended', 'updated' that have progress property)
*/
progressEventsSource = new BehaviorSubject<NotificationProgressEvent[]>([]);
progressEvents$ = this.progressEventsSource.asObservable();
singleUpdateSource = new BehaviorSubject<NotificationProgressEvent[]>([]);
singleUpdates$ = this.singleUpdateSource.asObservable();
errorSource = new BehaviorSubject<ErrorEvent[]>([]);
errors$ = this.errorSource.asObservable();
infoSource = new BehaviorSubject<InfoEvent[]>([]);
infos$ = this.infoSource.asObservable();
private updateNotificationModalRef: NgbModalRef | null = null;
activeEvents: number = 0;
debugMode: boolean = false;
get EVENTS() {
return EVENTS;
}
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
private accountService: AccountService, private confirmService: ConfirmService,
private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) { }
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
this.progressEventsSource.complete();
this.singleUpdateSource.complete();
this.errorSource.complete();
}
ngOnInit(): void {
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
if (event.event === EVENTS.NotificationProgress) {
this.processNotificationProgressEvent(event);
} else if (event.event === EVENTS.Error) {
const values = this.errorSource.getValue();
values.push(event.payload as ErrorEvent);
this.errorSource.next(values);
this.activeEvents += 1;
this.cdRef.markForCheck();
} else if (event.event === EVENTS.Info) {
const values = this.infoSource.getValue();
values.push(event.payload as InfoEvent);
this.infoSource.next(values);
this.activeEvents += 1;
this.cdRef.markForCheck();
}
});
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntil(this.onDestroy),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay()
);
}
processNotificationProgressEvent(event: Message<NotificationProgressEvent>) {
const message = event.payload as NotificationProgressEvent;
let data;
switch (event.payload.eventType) {
case 'single':
const values = this.singleUpdateSource.getValue();
values.push(message);
this.singleUpdateSource.next(values);
this.activeEvents += 1;
this.cdRef.markForCheck();
break;
case 'started':
// Sometimes we can receive 2 started on long running scans, so better to just treat as a merge then.
data = this.mergeOrUpdate(this.progressEventsSource.getValue(), message);
this.progressEventsSource.next(data);
break;
case 'updated':
data = this.mergeOrUpdate(this.progressEventsSource.getValue(), message);
this.progressEventsSource.next(data);
break;
case 'ended':
data = this.progressEventsSource.getValue();
data = data.filter(m => m.name !== message.name);
this.progressEventsSource.next(data);
this.activeEvents = Math.max(this.activeEvents - 1, 0);
this.cdRef.markForCheck();
break;
default:
break;
}
}
private mergeOrUpdate(data: NotificationProgressEvent[], message: NotificationProgressEvent) {
const index = data.findIndex(m => m.name === message.name);
// Sometimes we can receive 2 started on long running scans, so better to just treat as a merge then.
if (index < 0) {
data.push(message);
this.activeEvents += 1;
this.cdRef.markForCheck();
} else {
data[index] = message;
}
return data;
}
handleUpdateAvailableClick(message: NotificationProgressEvent) {
if (this.updateNotificationModalRef != null) { return; }
this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
this.updateNotificationModalRef.componentInstance.updateData = message.body as UpdateVersionEvent;
this.updateNotificationModalRef.closed.subscribe(() => {
this.updateNotificationModalRef = null;
});
this.updateNotificationModalRef.dismissed.subscribe(() => {
this.updateNotificationModalRef = null;
});
}
async seeMore(event: ErrorEvent | InfoEvent) {
const config = new ConfirmConfig();
if (event.name === EVENTS.Error) {
config.buttons = [
{text: 'Ok', type: 'secondary'},
{text: 'Dismiss', type: 'primary'}
];
} else {
config.buttons = [
{text: 'Ok', type: 'primary'},
];
}
config.header = event.title;
config.content = event.subTitle;
var result = await this.confirmService.alert(event.subTitle || event.title, config);
if (result) {
this.removeErrorOrInfo(event);
}
}
clearAllErrorOrInfos() {
const infoCount = this.infoSource.getValue().length;
const errorCount = this.errorSource.getValue().length;
this.infoSource.next([]);
this.errorSource.next([]);
this.activeEvents -= Math.max(infoCount + errorCount, 0);
this.cdRef.markForCheck();
}
removeErrorOrInfo(messageEvent: ErrorEvent | InfoEvent, event?: MouseEvent) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
let data = [];
if (messageEvent.name === EVENTS.Info) {
data = this.infoSource.getValue();
data = data.filter(m => m !== messageEvent);
this.infoSource.next(data);
} else {
data = this.errorSource.getValue();
data = data.filter(m => m !== messageEvent);
this.errorSource.next(data);
}
this.activeEvents = Math.max(this.activeEvents - 1, 0);
this.cdRef.markForCheck();
}
prettyPrintProgress(progress: number) {
return Math.trunc(progress * 100);
}
}

View file

@ -0,0 +1,117 @@
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
<div class="search">
<input #input [id]="id" type="text" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)" role="search"
>
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
<span class="visually-hidden">Loading...</span>
</div>
<button type="button" class="btn-close" aria-label="Close" (click)="resetField()" *ngIf="typeaheadForm.get('typeahead')?.value.length > 0"></button>
</div>
</div>
<div class="dropdown" *ngIf="hasFocus">
<ul class="list-group" role="listbox" id="dropdown">
<ng-container *ngIf="seriesTemplate !== undefined && grouppedData.series.length > 0">
<li class="list-group-item section-header"><h5 id="series-group">Series</h5></li>
<ul class="list-group results" role="group" aria-describedby="series-group">
<li *ngFor="let option of grouppedData.series; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" aria-labelledby="series-group" role="option">
<ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="collectionTemplate !== undefined && grouppedData.collections.length > 0">
<li class="list-group-item section-header"><h5>Collections</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.collections; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="readingListTemplate !== undefined && grouppedData.readingLists.length > 0">
<li class="list-group-item section-header"><h5>Reading Lists</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.readingLists; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="libraryTemplate !== undefined && grouppedData.libraries.length > 0">
<li class="list-group-item section-header"><h5 id="libraries-group">Libraries</h5></li>
<ul class="list-group results" role="group" aria-describedby="libraries-group">
<li *ngFor="let option of grouppedData.libraries; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" aria-labelledby="libraries-group" role="option">
<ng-container [ngTemplateOutlet]="libraryTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="genreTemplate !== undefined && grouppedData.genres.length > 0">
<li class="list-group-item section-header"><h5>Genres</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.genres; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="tagTemplate !== undefined && grouppedData.tags.length > 0">
<li class="list-group-item section-header"><h5>Tags</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.tags; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="personTemplate !== undefined && grouppedData.persons.length > 0">
<li class="list-group-item section-header"><h5>People</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.persons; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="chapterTemplate !== undefined && grouppedData.chapters.length > 0">
<li class="list-group-item section-header"><h5>Chapters</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.chapters; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="fileTemplate !== undefined && grouppedData.files.length > 0">
<li class="list-group-item section-header"><h5>Files</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.files; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="!hasData && searchTerm.length > 0">
<ul class="list-group results">
<li class="list-group-item">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
</li>
</ul>
</ng-container>
</ul>
</div>
</form>

View file

@ -0,0 +1,156 @@
form {
max-height: 38px;
}
.search-result img {
width: 100% !important;
}
.btn-close {
margin-top: 8px;
font-size: 0.8rem;
position: absolute;
right: 5px;
}
.typeahead-input {
border: 1px solid transparent;
border-radius: 4px;
padding: 0px 6px;
display: inline-block;
overflow: hidden;
position: relative;
z-index: 1;
box-sizing: border-box;
box-shadow: none;
cursor: text;
background-color: var(--input-bg-color);
color: var(--body-text-color);
min-height: 38px;
transition-property: all;
transition-duration: 0.3s;
display: block;
.search {
display: flex;
position: relative;
}
input {
outline: 0 !important;
border-radius: .28571429rem;
padding: 0px !important;
min-height: 0px !important;
max-width: 100% !important;
margin: 0px !important;
text-indent: 0 !important;
line-height: inherit !important;
box-shadow: none !important;
width: 300px;
transition-property: all;
transition-duration: 0.3s;
display: block;
opacity: 1;
position: relative;
left: 4px;
border: none;
&:focus-visible {
width: calc(100vw - 175px);
}
&:empty {
padding-top: 6px !important;
}
}
&.focused {
width: 99%;
border-color: var(--input-focused-border-color);
}
}
/* small devices (phones, 650px and down) */
@media only screen and (max-width:650px) {
input {
width: 100%
}
input:focus-visible {
width: 100% !important;
}
}
.section-header {
color: var(--body-text-color);
&:hover {
background-color: var(--list-group-item-bg-color) !important;
cursor: default;
}
}
.dropdown {
width: 100vw;
height: calc(100vh - 56px); //header offset
background: var(--dropdown-overlay-color);
position: fixed;
justify-content: center;
left: 0;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
margin-top: 9px;
}
.list-group {
max-width: 600px;
z-index:1000;
overflow-y: auto;
overflow-x: hidden;
display: block;
flex: auto;
max-height: calc(100vh - 58px);
height: fit-content;
}
.list-group.results {
max-height: unset;
}
@media only screen and (max-width: 600px) {
.list-group {
max-width: unset;
}
}
.list-group-item {
padding: 5px 10px;
}
li {
list-style: none;
border-radius: 0px !important;
margin: 0 !important;
}
ul ul {
border-radius: 0px !important;
}
.spinner-border {
position: absolute;
right: 10px;
margin: auto;
cursor: pointer;
top: 30%;
}

View file

@ -0,0 +1,193 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output, TemplateRef, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
@Component({
selector: 'app-grouped-typeahead',
templateUrl: './grouped-typeahead.component.html',
styleUrls: ['./grouped-typeahead.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
/**
* Unique id to tie with a label element
*/
@Input() id: string = 'grouped-typeahead';
/**
* Minimum number of characters in input to trigger a search
*/
@Input() minQueryLength: number = 0;
/**
* Initial value of the search model
*/
@Input() initialValue: string = '';
@Input() grouppedData: SearchResultGroup = new SearchResultGroup();
/**
* Placeholder for the input
*/
@Input() placeholder: string = '';
/**
* Number of milliseconds after typing before triggering inputChanged for data fetching
*/
@Input() debounceTime: number = 200;
/**
* Emits when the input changes from user interaction
*/
@Output() inputChanged: EventEmitter<string> = new EventEmitter();
/**
* Emits when something is clicked/selected
*/
@Output() selected: EventEmitter<any> = new EventEmitter();
/**
* Emits an event when the field is cleared
*/
@Output() clearField: EventEmitter<void> = new EventEmitter();
/**
* Emits when a change in the search field looses/gains focus
*/
@Output() focusChanged: EventEmitter<boolean> = new EventEmitter();
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
@ContentChild('itemTemplate') itemTemplate!: TemplateRef<any>;
@ContentChild('seriesTemplate') seriesTemplate: TemplateRef<any> | undefined;
@ContentChild('collectionTemplate') collectionTemplate: TemplateRef<any> | undefined;
@ContentChild('tagTemplate') tagTemplate: TemplateRef<any> | undefined;
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
@ContentChild('genreTemplate') genreTemplate!: TemplateRef<any>;
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
@ContentChild('libraryTemplate') libraryTemplate!: TemplateRef<any>;
@ContentChild('readingListTemplate') readingListTemplate!: TemplateRef<any>;
@ContentChild('fileTemplate') fileTemplate!: TemplateRef<any>;
@ContentChild('chapterTemplate') chapterTemplate!: TemplateRef<any>;
hasFocus: boolean = false;
isLoading: boolean = false;
typeaheadForm: FormGroup = new FormGroup({});
prevSearchTerm: string = '';
private onDestroy: Subject<void> = new Subject();
get searchTerm() {
return this.typeaheadForm.get('typeahead')?.value || '';
}
get hasData() {
return !(this.noResultsTemplate != undefined && !this.grouppedData.persons.length && !this.grouppedData.collections.length
&& !this.grouppedData.series.length && !this.grouppedData.persons.length && !this.grouppedData.tags.length && !this.grouppedData.genres.length && !this.grouppedData.libraries.length
&& !this.grouppedData.files.length && !this.grouppedData.chapters.length);
}
constructor(private readonly cdRef: ChangeDetectorRef) { }
@HostListener('window:click', ['$event'])
handleDocumentClick(event: any) {
this.close();
}
@HostListener('window:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (!this.hasFocus) { return; }
switch(event.key) {
case KEY_CODES.ESC_KEY:
this.close();
event.stopPropagation();
break;
default:
break;
}
}
ngOnInit(): void {
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
this.cdRef.markForCheck();
this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntil(this.onDestroy)).subscribe(change => {
const value = this.typeaheadForm.get('typeahead')?.value;
if (value != undefined && value != '' && !this.hasFocus) {
this.hasFocus = true;
this.cdRef.markForCheck();
}
if (value != undefined && value.length >= this.minQueryLength) {
if (this.prevSearchTerm === value) return;
this.inputChanged.emit(value);
this.prevSearchTerm = value;
this.cdRef.markForCheck();
}
});
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onInputFocus(event: any) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
this.openDropdown();
return this.hasFocus;
}
openDropdown() {
setTimeout(() => {
const model = this.typeaheadForm.get('typeahead');
if (model) {
model.setValue(model.value);
}
});
}
handleResultlick(item: any) {
this.selected.emit(item);
}
resetField() {
this.prevSearchTerm = '';
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.clearField.emit();
this.cdRef.markForCheck();
}
close(event?: FocusEvent) {
if (event) {
// If the user is tabbing out of the input field, check if there are results first before closing
if (this.hasData) {
return;
}
}
if (this.searchTerm === '') {
this.resetField();
}
this.hasFocus = false;
this.cdRef.markForCheck();
this.focusChanged.emit(this.hasFocus);
}
open(event?: FocusEvent) {
this.hasFocus = true;
this.focusChanged.emit(this.hasFocus);
this.cdRef.markForCheck();
}
public clear() {
this.prevSearchTerm = '';
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.cdRef.markForCheck();
}
}

View file

@ -0,0 +1,345 @@
<nav class="navbar navbar-expand-md navbar-dark fixed-top" *ngIf="navService?.navbarVisible$ | async">
<div class="container-fluid">
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<a class="side-nav-toggle" *ngIf="navService?.sideNavVisibility$ | async" (click)="hideSideNav()"><i class="fas fa-bars"></i></a>
<a class="navbar-brand dark-exempt" routerLink="/libraries" routerLinkActive="active">
<svg
width="28px"
height="28px"
viewBox="0 0 135.46666 135.46667"
version="1.1"
id="svg5"
xml:space="preserve"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g id="layer1"
transform="translate(-34.013356,-59.091761)">
<image
width="135.46666"
height="135.46666"
preserveAspectRatio="none"
style="image-rendering:optimizeQuality"
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAMAAADDpiTIAAACE1BMVEUAAABQvplGxZNXt6FO2sxG
wppKx5RJx5RIxpRHxZNGxZVJxpNLw5ZIxpRLyJFHw5BIxZJKxJVIyJRIx5RIxpRIxZRFxZNKyJZI
xpRKyJRJyZVIxZRKx5VKx5RJxpNHx5RHu4xXuJNJx5NJxpRJyJZMv49KxZNKxpNIyJFIw5NHxpNI
x5RJxZRHyZRJx5VJyJVIx5NJx5VIyJVHxZJIx5RHxpVHxpNHxJNFxJRGxI1JyJVIxZNJxZRJxJJM
x5VKyJRGx5RGxpNJx5NFxpNKx5dKxZNJyJRFzJlKxpZLxZVKyZRGxZVHxJZLyJNJxJZKxpT///9C
THLk5+/9/f3n6vFX0ffl6PD+/v9CSXHm6fD7+/1EZHhMypjl5uvl9u5FTnRLyJXR1eBLyJZEYXdM
zJhKypfj5u1JyZVMypfv+v5T0PdW0ffr7fPo6/Fx2Phd0vf5+vvv8fVKxZT5+/zt7/RCRnFEX3f3
+Pp02Pj19vld0/fO0t1CSHFGxZNCxJBIyJREUnU7RW1JwJJGg4BGjoRFd33X2uVEcnxIso5IrI1H
nohQWHxEaHk+SG9HmYdDV3XS8OO659Ov5M1+1bCGjKVey59It5D7/f1JvpHn9/Dg9Ou+wdCa3cFs
0KdgaIhEbXpDXXZIpIrw+vXEyNWk38V5gZvw8vbx+/5S0PfI69vFyNWus8SfpbhocI6J3vlu1/hO
0Pe/V/w3AAAAT3RSTlMABzIFAhLF0rRiSDYi6lMpbD/typ1XTPfA9vHm3KOHdRYL+vh9Du/ajlAe
1XE7reTNuyaW4LiLXToa+8eS+PPogSunffy9tgrzvvnzfvl+T9nnOAAAH1hJREFUeNrs3Flz2jAQ
AGBhMAaDua9yhiNAKBCOnDRp0yPpdDpT/QE9aDTS1P+if7017STNRSCEYEv7vfHEw0qrXa0AqcOv
9+LawDyuHU0/hYLGQclqfz//9ev8e9sqHRjB0KfpUe3YHGjxnu5HQBYBPRoxw63psDDqlNrnlNhC
MMboH/ia84kxJoRN6Hm71BkVhtNW2IxE9QACHvW1p+USZ6F0x2qQOVswSvGTKGXCJnMNq5MOnSVy
Wu8rAt5RnyTD3UOj1CAOwSh+JsoEcTRKxmE3nJzUEXC5HS1bPTRSzNnwjOIXQpmTEljKOKxmtR0E
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
"
id="image71"
x="34.013355"
y="59.091763"/></g></svg>
<span class="d-none d-md-inline logo"> Kavita</span>
</a>
<ul class="navbar-nav col me-auto">
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<label for="nav-search" class="form-label visually-hidden">Search series</label>
<div class="ng-autocomplete">
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
<ng-template #libraryTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
<div class="ms-1">
<span>{{item.name}}</span>
</div>
</div>
</ng-template>
<ng-template #seriesTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
</div>
<div class="ms-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<div class="text-light fst-italic" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
</ng-template>
<ng-template #collectionTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
</div>
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
</ng-template>
<ng-template #readingListTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
<div class="ms-1">
<span>{{item.title}}</span>
</div>
</div>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ms-1">
<div [innerHTML]="item.name"></div>
<div class="text-light fst-italic">{{item.role | personRole}}</div>
</div>
</div>
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div class="ms-1">
<div [innerHTML]="item.title"></div>
</div>
</div>
</ng-template>
<ng-template #chapterTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="clickChapterSearchResult(item)">
<div class="ms-1">
<ng-container *ngIf="item.files.length > 0">
<app-series-format [format]="item.files[0].format"></app-series-format>
</ng-container>
<span>{{item.titleName}}</span>
</div>
</div>
</ng-template>
<ng-template #fileTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickFileSearchResult(item)">
<div class="ms-1">
<app-series-format [format]="item.format"></app-series-format>
<span>{{item.filePath}}</span>
</div>
</div>
</ng-template>
<ng-template #noResultsTemplate let-notFound>
No results found
</ng-template>
</app-grouped-typeahead>
</div>
</div>
</ul>
<ng-container *ngIf="!searchFocused">
<div class="back-to-top" *ngIf="backToTopNeeded">
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()">
<i class="fa fa-angle-double-up nav" aria-hidden="true"></i>
<span class="visually-hidden">Scroll to Top</span>
</button>
</div>
<ng-container *ngIf="(accountService.currentUser$ | async) as user">
<div class="nav-item">
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
</div>
<div class="nav-item not-xs-only">
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt btn btn-icon" title="Server Settings">
<i class="fa fa-cogs nav" aria-hidden="true"></i>
<span class="visually-hidden">Server Settings</span>
</a>
</div>
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
<i class="fa-solid fa-user-circle align-self-center phone-hidden d-xs-inline-block d-sm-inline-block d-md-none"></i><span class="d-none d-xs-none d-sm-none d-md-inline-block">{{user.username | sentenceCase}}</span>
</button>
<div ngbDropdownMenu>
<a class="xs-only" ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</a>
<a ngbDropdownItem routerLink="/preferences/">Settings</a>
<a ngbDropdownItem href="https://wiki.kavitareader.com" rel="noopener noreferrer" target="_blank">Help</a>
<a ngbDropdownItem routerLink="/announcements/" *ngIf="accountService.hasAdminRole(user)">Announcements</a>
<a ngbDropdownItem (click)="logout()">Logout</a>
</div>
</div>
</ng-container>
</ng-container>
</div>
</nav>

View file

@ -0,0 +1,91 @@
.btn:focus, .btn:hover {
box-shadow: 0 0 0 0.1rem var(--navbar-btn-hover-outline-color);
}
.navbar {
background-color: var(--navbar-bg-color);
.side-nav-toggle {
cursor: pointer;
margin-left: 13px;
font-size: 1.2rem;
i {
color: var(--navbar-fa-icon-color);
}
}
}
/* small devices (phones, 650px and down) */
@media only screen and (max-width:650px) {
.navbar-nav {
width: 0;
}
}
// On Really small screens, hide the server settings wheel and show it in nav
// TODO: Look into doing this with bootstrap 5 (and moving to _utilities.scss)
.xs-only {
display: none;
}
.not-xs-only {
display: inherit;
}
@media only screen and (max-width:300px) {
.xs-only {
display: inherit;
}
.not-xs-only {
display: none;
}
}
.nav-item.dropdown {
position: unset;
}
.navbar-brand {
font-family: var(--brand-font-family);
font-weight: bold;
margin: 0 1rem;
&:hover {
text-decoration: none;
}
.logo {
max-height: 28px;
vertical-align: text-top;
}
.phone-hidden {
vertical-align: middle;
}
}
.focus-visible:focus {
visibility: visible;
color: var(--nav-header-text-color);
}
.ng-autocomplete {
margin-bottom: 0px;
}
.primary-text {
color: var(--nav-header-text-color);
border: none;
}
.search-result {
width: 24px;
margin-top: 5px;
}
.scroll-to-top:hover {
animation: MoveUpDown 1s linear infinite;
}
.text-light {
color: var(--search-result-text-lite-color) !important;
}

View file

@ -0,0 +1,231 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { fromEvent, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs/operators';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { Chapter } from 'src/app/_models/chapter';
import { CollectionTag } from 'src/app/_models/collection-tag';
import { Library } from 'src/app/_models/library';
import { MangaFile } from 'src/app/_models/manga-file';
import { PersonRole } from 'src/app/_models/metadata/person';
import { ReadingList } from 'src/app/_models/reading-list';
import { SearchResult } from 'src/app/_models/search/search-result';
import { SearchResultGroup } from 'src/app/_models/search/search-result-group';
import { AccountService } from 'src/app/_services/account.service';
import { ImageService } from 'src/app/_services/image.service';
import { NavService } from 'src/app/_services/nav.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SearchService } from 'src/app/_services/search.service';
@Component({
selector: 'app-nav-header',
templateUrl: './nav-header.component.html',
styleUrls: ['./nav-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NavHeaderComponent implements OnInit, OnDestroy {
@ViewChild('search') searchViewRef!: any;
isLoading = false;
debounceTime = 300;
imageStyles = {width: '24px', 'margin-top': '5px'};
searchResults: SearchResultGroup = new SearchResultGroup();
searchTerm = '';
customFilter: (items: SearchResult[], query: string) => SearchResult[] = (items: SearchResult[], query: string) => {
const normalizedQuery = query.trim().toLowerCase();
const matches = items.filter(item => {
const normalizedSeriesName = item.name.toLowerCase().trim();
const normalizedOriginalName = item.originalName.toLowerCase().trim();
const normalizedLocalizedName = item.localizedName.toLowerCase().trim();
return normalizedSeriesName.indexOf(normalizedQuery) >= 0 || normalizedOriginalName.indexOf(normalizedQuery) >= 0 || normalizedLocalizedName.indexOf(normalizedQuery) >= 0;
});
return matches;
};
backToTopNeeded = false;
searchFocused: boolean = false;
scrollElem: HTMLElement;
private readonly onDestroy = new Subject<void>();
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
this.scrollElem = this.document.body;
}
ngOnInit(): void {
this.scrollService.scrollContainer$.pipe(distinctUntilChanged(), takeUntil(this.onDestroy), tap((scrollContainer) => {
if (scrollContainer === 'body' || scrollContainer === undefined) {
this.scrollElem = this.document.body;
fromEvent(this.document.body, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(this.document.body));
} else {
const elem = scrollContainer as ElementRef<HTMLDivElement>;
this.scrollElem = elem.nativeElement;
fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement));
}
})).subscribe();
// Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate
this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe(() => {
this.checkBackToTopNeeded(this.scrollElem);
});
}
checkBackToTopNeeded(elem: HTMLElement) {
const offset = elem.scrollTop || 0;
if (offset > 100) {
this.backToTopNeeded = true;
} else if (offset < 40) {
this.backToTopNeeded = false;
}
this.cdRef.markForCheck();
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
logout() {
this.accountService.logout();
this.navService.hideNavBar();
this.navService.hideSideNav();
this.router.navigateByUrl('/login');
}
moveFocus() {
this.document.getElementById('content')?.focus();
}
onChangeSearch(val: string) {
this.isLoading = true;
this.searchTerm = val.trim();
this.cdRef.markForCheck();
this.searchService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchResults = results;
this.isLoading = false;
this.cdRef.markForCheck();
}, err => {
this.searchResults.reset();
this.isLoading = false;
this.searchTerm = '';
this.cdRef.markForCheck();
});
}
goTo(queryParamName: string, filter: any) {
let params: any = {};
params[queryParamName] = filter;
params[FilterQueryParam.Page] = 1;
this.clearSearch();
this.router.navigate(['all-series'], {queryParams: params});
}
goToPerson(role: PersonRole, filter: any) {
this.clearSearch();
switch(role) {
case PersonRole.Writer:
this.goTo(FilterQueryParam.Writers, filter);
break;
case PersonRole.Artist:
this.goTo(FilterQueryParam.Artists, filter);
break;
case PersonRole.Character:
this.goTo(FilterQueryParam.Character, filter);
break;
case PersonRole.Colorist:
this.goTo(FilterQueryParam.Colorist, filter);
break;
case PersonRole.Editor:
this.goTo(FilterQueryParam.Editor, filter);
break;
case PersonRole.Inker:
this.goTo(FilterQueryParam.Inker, filter);
break;
case PersonRole.CoverArtist:
this.goTo(FilterQueryParam.CoverArtists, filter);
break;
case PersonRole.Letterer:
this.goTo(FilterQueryParam.Letterer, filter);
break;
case PersonRole.Penciller:
this.goTo(FilterQueryParam.Penciller, filter);
break;
case PersonRole.Publisher:
this.goTo(FilterQueryParam.Publisher, filter);
break;
case PersonRole.Translator:
this.goTo(FilterQueryParam.Translator, filter);
break;
}
}
clearSearch() {
this.searchViewRef.clear();
this.searchTerm = '';
this.searchResults = new SearchResultGroup();
this.cdRef.markForCheck();
}
clickSeriesSearchResult(item: SearchResult) {
this.clearSearch();
const libraryId = item.libraryId;
const seriesId = item.seriesId;
this.router.navigate(['library', libraryId, 'series', seriesId]);
}
clickFileSearchResult(item: MangaFile) {
this.clearSearch();
this.searchService.getSeriesForMangaFile(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
});
}
clickChapterSearchResult(item: Chapter) {
this.clearSearch();
this.searchService.getSeriesForChapter(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
});
}
clickLibraryResult(item: Library) {
this.clearSearch();
this.router.navigate(['library', item.id]);
}
clickCollectionSearchResult(item: CollectionTag) {
this.clearSearch();
this.router.navigate(['collections', item.id]);
}
clickReadingListSearchResult(item: ReadingList) {
this.clearSearch();
this.router.navigate(['lists', item.id]);
}
scrollToTop() {
this.scrollService.scrollTo(0, this.scrollElem);
}
focusUpdate(searchFocused: boolean) {
this.searchFocused = searchFocused;
this.cdRef.markForCheck();
}
hideSideNav() {
this.navService.toggleSideNav();
}
}