Library Recomendations (#1236)
* Updated cover regex for finding cover images in archives to ignore back_cover or back-cover * Fixed an issue where Tags wouldn't save due to not pulling them from the DB. * Refactored All series to it's own lazy loaded module * Modularized Dashboard and library detail. Had to change main dashboard page to be libraries. Subject to change. * Refactored login component into registration module * Series Detail module created * Refactored nav stuff into it's own module, not lazy loaded, but self contained. * Refactored theme component into a dev only module so we don't incur load for temp testing modules * Finished off modularization code. Only missing thing is to re-introduce some dashboard functionality for library view. * Implemented a basic recommendation page for library detail
This commit is contained in:
parent
743a3ba935
commit
f237aa7ab7
77 changed files with 1077 additions and 501 deletions
109
UI/Web/src/app/nav/events-widget/events-widget.component.html
Normal file
109
UI/Web/src/app/nav/events-widget/events-widget.component.html
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<ng-container *ngIf="isAdmin">
|
||||
|
||||
<button type="button" class="btn btn-icon {{activeEvents > 0 ? 'colored' : ''}}"
|
||||
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'" [autoClose]="'outside'">
|
||||
<i aria-hidden="true" class="fa fa-wave-square nav"></i>
|
||||
</button>
|
||||
|
||||
<ng-template #popContent>
|
||||
<ul class="list-group list-group-flush dark-menu">
|
||||
|
||||
<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>
|
||||
</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 !== ''">{{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> 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>
|
||||
|
||||
<!-- 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)="seeMoreError(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)="removeError(error, $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 < 1 && 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>
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
166
UI/Web/src/app/nav/events-widget/events-widget.component.ts
Normal file
166
UI/Web/src/app/nav/events-widget/events-widget.component.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { 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 { ErrorEvent } from 'src/app/_models/events/error-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']
|
||||
})
|
||||
export class EventsWidgetComponent implements OnInit, OnDestroy {
|
||||
@Input() user!: User;
|
||||
|
||||
isAdmin: boolean = 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();
|
||||
|
||||
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) { }
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
this.progressEventsSource.complete();
|
||||
this.singleUpdateSource.complete();
|
||||
this.errorSource.complete();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Debounce for testing. Kavita's too fast
|
||||
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.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
} else {
|
||||
this.isAdmin = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
processNotificationProgressEvent(event: Message<NotificationProgressEvent>) {
|
||||
const message = event.payload as NotificationProgressEvent;
|
||||
let data;
|
||||
let index = -1;
|
||||
switch (event.payload.eventType) {
|
||||
case 'single':
|
||||
const values = this.singleUpdateSource.getValue();
|
||||
values.push(message);
|
||||
this.singleUpdateSource.next(values);
|
||||
this.activeEvents += 1;
|
||||
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);
|
||||
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;
|
||||
} 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 seeMoreError(error: ErrorEvent) {
|
||||
const config = new ConfirmConfig();
|
||||
config.buttons = [
|
||||
{text: 'Dismiss', type: 'primary'},
|
||||
{text: 'Ok', type: 'secondary'},
|
||||
];
|
||||
config.header = error.title;
|
||||
config.content = error.subTitle;
|
||||
var result = await this.confirmService.alert(error.subTitle || error.title, config);
|
||||
if (result) {
|
||||
this.removeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
removeError(error: ErrorEvent, event?: MouseEvent) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
let data = this.errorSource.getValue();
|
||||
data = data.filter(m => m !== error);
|
||||
this.errorSource.next(data);
|
||||
this.activeEvents = Math.max(this.activeEvents - 1, 0);
|
||||
}
|
||||
|
||||
prettyPrintProgress(progress: number) {
|
||||
return Math.trunc(progress * 100);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<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)"
|
||||
>
|
||||
<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()">
|
||||
|
||||
</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="noResultsTemplate != undefined && searchTerm.length > 0 && !grouppedData.persons.length && !grouppedData.collections.length
|
||||
&& !grouppedData.series.length && !grouppedData.persons.length && !grouppedData.tags.length && !grouppedData.genres.length && !grouppedData.libraries.length">
|
||||
<ul class="list-group results">
|
||||
<li class="list-group-item">
|
||||
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</ng-container>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
|
@ -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: 100%;
|
||||
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 - 57px); //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%;
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { 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 '../../shared/_services/utility.service';
|
||||
import { SearchResultGroup } from '../../_models/search/search-result-group';
|
||||
|
||||
@Component({
|
||||
selector: 'app-grouped-typeahead',
|
||||
templateUrl: './grouped-typeahead.component.html',
|
||||
styleUrls: ['./grouped-typeahead.component.scss']
|
||||
})
|
||||
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>;
|
||||
|
||||
|
||||
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.grouppedData.persons.length || this.grouppedData.collections.length || this.grouppedData.series.length || this.grouppedData.persons.length || this.grouppedData.tags.length || this.grouppedData.genres.length;
|
||||
}
|
||||
|
||||
|
||||
constructor() { }
|
||||
|
||||
@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.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;
|
||||
}
|
||||
|
||||
if (value != undefined && value.length >= this.minQueryLength) {
|
||||
|
||||
if (this.prevSearchTerm === value) return;
|
||||
this.inputChanged.emit(value);
|
||||
this.prevSearchTerm = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
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.focusChanged.emit(this.hasFocus);
|
||||
}
|
||||
|
||||
open(event?: FocusEvent) {
|
||||
this.hasFocus = true;
|
||||
this.focusChanged.emit(this.hasFocus);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this.prevSearchTerm = '';
|
||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||
}
|
||||
|
||||
}
|
||||
145
UI/Web/src/app/nav/nav-header/nav-header.component.html
Normal file
145
UI/Web/src/app/nav/nav-header/nav-header.component.html
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<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"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="d-none d-md-inline"> 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="form-text" 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">
|
||||
<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">
|
||||
<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>{{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 #noResultsTemplate let-notFound>
|
||||
No results found
|
||||
</ng-template>
|
||||
|
||||
</app-grouped-typeahead>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<ng-container *ngIf="!searchFocused">
|
||||
<div class="back-to-top">
|
||||
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
|
||||
<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="noreferrer" target="_blank">Help</a>
|
||||
<a ngbDropdownItem routerLink="/announcements/" *ngIf="accountService.hasAdminRole(user)">Annoucements</a>
|
||||
<a ngbDropdownItem (click)="logout()">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
87
UI/Web/src/app/nav/nav-header/nav-header.component.scss
Normal file
87
UI/Web/src/app/nav/nav-header/nav-header.component.scss
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
|
||||
.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: 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;
|
||||
}
|
||||
184
UI/Web/src/app/nav/nav-header/nav-header.component.ts
Normal file
184
UI/Web/src/app/nav/nav-header/nav-header.component.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
|
||||
import { CollectionTag } from '../../_models/collection-tag';
|
||||
import { Library } from '../../_models/library';
|
||||
import { PersonRole } from '../../_models/person';
|
||||
import { ReadingList } from '../../_models/reading-list';
|
||||
import { SearchResult } from '../../_models/search-result';
|
||||
import { SearchResultGroup } from '../../_models/search/search-result-group';
|
||||
import { AccountService } from '../../_services/account.service';
|
||||
import { ImageService } from '../../_services/image.service';
|
||||
import { LibraryService } from '../../_services/library.service';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-header',
|
||||
templateUrl: './nav-header.component.html',
|
||||
styleUrls: ['./nav-header.component.scss']
|
||||
})
|
||||
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;
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService) { }
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
@HostListener('body:scroll', [])
|
||||
checkBackToTopNeeded() {
|
||||
const offset = this.scrollService.scrollPosition;
|
||||
if (offset > 100) {
|
||||
this.backToTopNeeded = true;
|
||||
} else if (offset < 40) {
|
||||
this.backToTopNeeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
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.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
this.searchResults = results;
|
||||
this.isLoading = false;
|
||||
}, err => {
|
||||
this.searchResults.reset();
|
||||
this.isLoading = false;
|
||||
this.searchTerm = '';
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
clickSeriesSearchResult(item: SearchResult) {
|
||||
this.clearSearch();
|
||||
const libraryId = item.libraryId;
|
||||
const seriesId = item.seriesId;
|
||||
this.router.navigate(['library', libraryId, 'series', seriesId]);
|
||||
}
|
||||
|
||||
clickLibraryResult(item: Library) {
|
||||
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.document.body);
|
||||
}
|
||||
|
||||
focusUpdate(searchFocused: boolean) {
|
||||
this.searchFocused = searchFocused
|
||||
return searchFocused;
|
||||
}
|
||||
|
||||
hideSideNav() {
|
||||
this.navService.toggleSideNav();
|
||||
}
|
||||
}
|
||||
37
UI/Web/src/app/nav/nav.module.ts
Normal file
37
UI/Web/src/app/nav/nav.module.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventsWidgetComponent } from './events-widget/events-widget.component';
|
||||
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
|
||||
import { NavHeaderComponent } from './nav-header/nav-header.component';
|
||||
import { NgbDropdownModule, NgbPopoverModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { TypeaheadModule } from '../typeahead/typeahead.module';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
NavHeaderComponent,
|
||||
EventsWidgetComponent,
|
||||
GroupedTypeaheadComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
|
||||
NgbDropdownModule,
|
||||
NgbPopoverModule,
|
||||
NgbNavModule,
|
||||
|
||||
SharedModule, // app image, series-format
|
||||
PipeModule,
|
||||
TypeaheadModule,
|
||||
],
|
||||
exports: [
|
||||
NavHeaderComponent,
|
||||
SharedModule
|
||||
]
|
||||
})
|
||||
export class NavModule { }
|
||||
Loading…
Add table
Add a link
Reference in a new issue