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:
parent
e2470cba88
commit
ab9021cb32
198 changed files with 263 additions and 270 deletions
|
|
@ -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> 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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%;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
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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">
|
||||
<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 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>
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue