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:
Joseph Milazzo 2022-04-29 17:27:01 -05:00 committed by GitHub
parent 743a3ba935
commit f237aa7ab7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 1077 additions and 501 deletions

View 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>&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>
<!-- 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>

View file

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

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

View file

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

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: 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%;
}

View file

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

View 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">
&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>{{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>

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

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

View 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 { }