UX Overhaul Part 1 (#3047)

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
Robbie Davis 2024-08-09 13:55:31 -04:00 committed by GitHub
parent 5934d516f3
commit ff79710ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
324 changed files with 11589 additions and 4598 deletions

View file

@ -1,6 +1,6 @@
<ng-container *transloco="let t; read: 'events-widget'">
@if (isAdmin$ | async) {
@if (accountService.isAdmin$ | async) {
@if (downloadService.activeDownloads$ | async; as activeDownloads) {
@if (errors$ | async; as errors) {
@if (infos$ | async; as infos) {
@ -11,16 +11,17 @@
[autoClose]="'outside'">
@if (onlineUsers.length > 1) {
<span class="me-2" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}">{{onlineUsers.length}}</span>
<span class="me-2" [ngClass]="{'colored': ((activeEvents$ | async) || 0) > 0 || activeDownloads.length > 0 || updateAvailable}">{{onlineUsers.length}}</span>
}
<i aria-hidden="true" class="fa fa-wave-square nav" [ngClass]="{'colored': activeEvents > 0 || activeDownloads.length > 0 || updateAvailable}"></i>
<i aria-hidden="true" class="fa fa-wave-square nav" [ngClass]="{'colored': ((activeEvents$ | async) || 0) > 0 || activeDownloads.length > 0 || updateAvailable}"></i>
@if (errors.length > 0) {
<i aria-hidden="true" class="fa fa-circle-exclamation nav widget-button--indicator error"></i>
} @else if (infos.length > 0) {
<i aria-hidden="true" class="fa fa-circle-info nav widget-button--indicator info"></i>
} @else if (activeEvents > 0 || activeDownloads.length > 0) {
} @else if (((activeEvents$ | async) || 0) > 0 || activeDownloads.length > 0) {
<div class="nav widget-button--indicator spinner-border spinner-border-sm"></div>
} @else if (updateAvailable) {
<i aria-hidden="true" class="fa fa-circle-arrow-up nav widget-button--indicator update"></i>

View file

@ -65,10 +65,6 @@
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;

View file

@ -9,8 +9,7 @@ import {
OnInit
} from '@angular/core';
import { NgbModal, NgbModalRef, NgbPopover } from '@ng-bootstrap/ng-bootstrap';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import {BehaviorSubject, debounceTime, startWith} from 'rxjs';
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';
@ -40,7 +39,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
public readonly downloadService = inject(DownloadService);
public readonly messageHub = inject(MessageHubService);
private readonly modalService = inject(NgbModal);
private readonly accountService = inject(AccountService);
protected readonly accountService = inject(AccountService);
private readonly confirmService = inject(ConfirmService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
@ -48,8 +47,6 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
@Input({required: true}) user!: User;
isAdmin$: Observable<boolean> = of(false);
/**
* Progress events (Event Type: 'started', 'ended', 'updated' that have progress property)
*/
@ -67,6 +64,8 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
private updateNotificationModalRef: NgbModalRef | null = null;
activeEventsSource = new BehaviorSubject<number>(0);
activeEvents$ = this.activeEventsSource.asObservable().pipe(startWith(0), takeUntilDestroyed(this.destroyRef), debounceTime(100));
activeEvents: number = 0;
/**
* Intercepts from Single Updates to show an extra indicator to the user
@ -93,23 +92,19 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
values.push(event.payload as ErrorEvent);
this.errorSource.next(values);
this.activeEvents += 1;
this.activeEventsSource.next(this.activeEvents);
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.activeEventsSource.next(this.activeEvents);
this.cdRef.markForCheck();
} else if (event.event === EVENTS.UpdateAvailable) {
this.handleUpdateAvailableClick(event.payload);
}
});
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
shareReplay()
);
}
processNotificationProgressEvent(event: Message<NotificationProgressEvent>) {
@ -121,6 +116,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
values.push(message);
this.singleUpdateSource.next(values);
this.activeEvents += 1;
this.activeEventsSource.next(this.activeEvents);
if (event.payload.name === EVENTS.UpdateAvailable) {
this.updateAvailable = true;
}
@ -140,6 +136,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
data = data.filter(m => m.name !== message.name);
this.progressEventsSource.next(data);
this.activeEvents = Math.max(this.activeEvents - 1, 0);
this.activeEventsSource.next(this.activeEvents);
this.cdRef.markForCheck();
break;
default:
@ -153,6 +150,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
if (index < 0) {
data.push(message);
this.activeEvents += 1;
this.activeEventsSource.next(this.activeEvents);
this.cdRef.markForCheck();
} else {
data[index] = message;
@ -204,6 +202,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
this.infoSource.next([]);
this.errorSource.next([]);
this.activeEvents -= Math.max(infoCount + errorCount, 0);
this.activeEventsSource.next(this.activeEvents);
this.cdRef.markForCheck();
}
@ -223,6 +222,7 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
this.errorSource.next(data);
}
this.activeEvents = Math.max(this.activeEvents - 1, 0);
this.activeEventsSource.next(this.activeEvents);
this.cdRef.markForCheck();
}

View file

@ -18,156 +18,157 @@
}
</div>
</div>
@if (hasFocus) {
<div class="dropdown">
<ul class="list-group" role="listbox" id="dropdown">
@if (hasFocus) {
<div class="overlay">
<div class="dropdown">
<ul class="list-group" role="listbox" id="dropdown">
@if (seriesTemplate !== undefined && groupedData.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">
@for(option of groupedData.series; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" aria-labelledby="series-group" role="option">
<ng-container [ngTemplateOutlet]="seriesTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
@if (seriesTemplate !== undefined && groupedData.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">
@for(option of groupedData.series; track option; let index = $index) {
<li (click)="handleResultClick(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>
}
@if (collectionTemplate !== undefined && groupedData.collections.length > 0) {
<li class="list-group-item section-header"><h5>{{t('collections')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.collections; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (readingListTemplate !== undefined && groupedData.readingLists.length > 0) {
<li class="list-group-item section-header"><h5>{{t('reading-lists')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.readingLists; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (bookmarkTemplate !== undefined && groupedData.bookmarks.length > 0) {
<li class="list-group-item section-header"><h5>{{t('bookmarks')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.bookmarks; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="bookmarkTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (libraryTemplate !== undefined && groupedData.libraries.length > 0) {
<li class="list-group-item section-header"><h5 id="libraries-group">{{t('libraries')}}</h5></li>
<ul class="list-group results" role="group" aria-describedby="libraries-group">
@for(option of groupedData.libraries; track option; let index = $index) {
<li (click)="handleResultClick(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>
}
@if (genreTemplate !== undefined && groupedData.genres.length > 0) {
<li class="list-group-item section-header"><h5>{{t('genres')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.genres; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (tagTemplate !== undefined && groupedData.tags.length > 0) {
<li class="list-group-item section-header"><h5>{{t('tags')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.tags; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (personTemplate !== undefined && groupedData.persons.length > 0) {
<li class="list-group-item section-header"><h5>{{t('people')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.persons; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (chapterTemplate !== undefined && groupedData.chapters.length > 0) {
<li class="list-group-item section-header"><h5>{{t('chapters')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.chapters; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (fileTemplate !== undefined && groupedData.files.length > 0) {
<li class="list-group-item section-header"><h5>{{t('files')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.files; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (!hasData && searchTerm.length > 0 && !isLoading) {
<ul class="list-group results">
<li class="list-group-item">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
</li>
}
</ul>
}
</ul>
}
@if (collectionTemplate !== undefined && groupedData.collections.length > 0) {
<li class="list-group-item section-header"><h5>{{t('collections')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.collections; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="collectionTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (readingListTemplate !== undefined && groupedData.readingLists.length > 0) {
<li class="list-group-item section-header"><h5>{{t('reading-lists')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.readingLists; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="readingListTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (bookmarkTemplate !== undefined && groupedData.bookmarks.length > 0) {
<li class="list-group-item section-header"><h5>{{t('bookmarks')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.bookmarks; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="bookmarkTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (libraryTemplate !== undefined && groupedData.libraries.length > 0) {
<li class="list-group-item section-header"><h5 id="libraries-group">{{t('libraries')}}</h5></li>
<ul class="list-group results" role="group" aria-describedby="libraries-group">
@for(option of groupedData.libraries; track option; let index = $index) {
<li (click)="handleResultClick(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>
}
@if (genreTemplate !== undefined && groupedData.genres.length > 0) {
<li class="list-group-item section-header"><h5>{{t('genres')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.genres; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="genreTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (tagTemplate !== undefined && groupedData.tags.length > 0) {
<li class="list-group-item section-header"><h5>{{t('tags')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.tags; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="tagTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (personTemplate !== undefined && groupedData.persons.length > 0) {
<li class="list-group-item section-header"><h5>{{t('people')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.persons; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (chapterTemplate !== undefined && groupedData.chapters.length > 0) {
<li class="list-group-item section-header"><h5>{{t('chapters')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.chapters; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="chapterTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (fileTemplate !== undefined && groupedData.files.length > 0) {
<li class="list-group-item section-header"><h5>{{t('files')}}</h5></li>
<ul class="list-group results">
@for(option of groupedData.files; track option; let index = $index) {
<li (click)="handleResultClick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="fileTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
}
</ul>
}
@if (!hasData && searchTerm.length > 0 && !isLoading) {
<ul class="list-group results">
<li class="list-group-item">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
@if (searchTerm.length > 0 && !isLoading) {
<li class="list-group-item" style="min-height: 34px" (click)="$event.stopPropagation()">
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
<form [formGroup]="searchSettingsForm">
<div class="form-check form-switch">
<input type="checkbox" id="search-include-extras" role="switch" formControlName="includeExtras" class="form-check-input"
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label" for="search-include-extras">{{t('include-extras')}}</label>
</div>
</form>
</li>
</ul>
}
@if (hasData && (isAdmin$ | async)) {
<li class="list-group-item" style="min-height: 34px" (click)="$event.stopPropagation()">
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
<form [formGroup]="searchSettingsForm">
<div class="form-check form-switch">
<input type="checkbox" id="search-include-extras" role="switch" formControlName="includeExtras" class="form-check-input"
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label" for="search-include-extras">{{t('include-extras')}}</label>
</div>
</form>
</li>
}
</ul>
}
</ul>
</div>
</div>
}

View file

@ -68,7 +68,7 @@ form {
}
&.focused {
width: 99%;
width: 98.5%;
border-color: var(--input-focused-border-color);
}
@ -92,15 +92,19 @@ form {
color: var(--body-text-color);
&:hover {
background-color: var(--list-group-item-bg-color) !important;
background-color: var(--search-list-group-item-bg-color, black) !important;
cursor: default;
}
}
.list-group-item {
background-color: var(--search-list-group-item-bg-color, black);
}
.dropdown {
width: 100vw;
height: calc(var(--vh)*100 - 56px); //header offset
background: var(--dropdown-overlay-color);
top: 55px; //header offset
position: fixed;
justify-content: center;
left: 0;
@ -117,7 +121,7 @@ form {
overflow-x: hidden;
display: block;
flex: auto;
max-height: calc(100vh - 58px);
max-height: calc((var(--vh) * 100) - var(--nav-offset));
height: fit-content;
}
@ -154,3 +158,12 @@ ul ul {
cursor: pointer;
top: 30%;
}
.overlay {
position: absolute;
top: 0;
left: 0;
background: rgba(0,0,0,0.4);
width: 100%;
height: 100dvh;
}

View file

@ -105,10 +105,6 @@ export class GroupedTypeaheadComponent implements OnInit {
includeChapterAndFiles: boolean = false;
prevSearchTerm: string = '';
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
isAdmin$ = this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), map(u => {
if (!u) return false;
return this.accountService.hasAdminRole(u);
}));
get searchTerm() {
return this.typeaheadForm.get('typeahead')?.value || '';

View file

@ -1,195 +1,204 @@
<ng-container *transloco="let t; read: 'nav-header'">
<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()">{{t('skip-alt')}}</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="/home" routerLinkActive="active">
<app-image width="28px" height="28px" imageUrl="assets/images/logo-32.png" classes="logo" />
<span class="d-none d-md-inline logo"> Kavita</span>
</a>
<ul class="navbar-nav col me-auto">
@if (navService.navbarVisible$ | async) {
<nav class="navbar navbar-expand-md navbar-dark fixed-top">
<div class="container-fluid">
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">{{t('skip-alt')}}</a>
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<label for="nav-search" class="form-label visually-hidden">{{t('search-series-alt')}}</label>
<div class="ng-autocomplete">
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
[isLoading]="isLoading"
initialValue=""
[placeholder]="t('search-alt')"
[groupedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
@if (navService.sideNavVisibility$ | async) {
<a class="side-nav-toggle" (click)="hideSideNav()"><i class="fas fa-bars"></i></a>
}
<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>
<ng-container *ngIf="searchTerm.toLowerCase().trim() as st">
<span *ngIf="item.name.toLowerCase().trim().indexOf(st) >= 0; else localizedName">{{item.name}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
</ng-container>
<a class="navbar-brand dark-exempt" routerLink="/home" routerLinkActive="active">
<app-image width="28px" height="28px" imageUrl="assets/images/logo-32.png" classes="logo" />
<span class="d-none d-md-inline logo"> Kavita</span>
</a>
<ul class="navbar-nav col me-auto">
@if (accountService.currentUser$ | async; as user) {
<div class="nav-item">
<label for="nav-search" class="form-label visually-hidden">{{t('search-series-alt')}}</label>
<div class="ng-autocomplete">
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
[isLoading]="isLoading"
initialValue=""
[placeholder]="t('search-alt')"
[groupedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
<div class="text-light fst-italic" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
</ng-template>
<ng-template #bookmarkTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickBookmarkSearchResult(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>
<ng-container *ngIf="searchTerm.toLowerCase().trim() as st">
<span *ngIf="item.seriesName.toLowerCase().trim().indexOf(st) >= 0; else localizedName">{{item.seriesName}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedSeriesName"></span>
</ng-template>
</ng-container>
</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">
<div>
<span>{{item.title}}</span>
<app-promoted-icon [promoted]="item.promoted"></app-promoted-icon>
<ng-template #libraryTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
<div class="ms-1">
<span>{{item.name}}</span>
</div>
</div>
<app-collection-owner [collection]="item"></app-collection-owner>
</div>
</div>
</ng-template>
</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]="t('promoted')"></i>
<span class="visually-hidden">{{t('promoted')}}</span>
</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>
@if (searchTerm.toLowerCase().trim(); as st) {
@if (item.name.toLowerCase().trim().indexOf(st) >= 0) {
<span>{{item.name}}</span>
} @else {
<span [innerHTML]="item.localizedName"></span>
}
}
<div class="text-light fst-italic" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goToOther(FilterField.Tags, item.id)">
<div class="ms-1">
<span>{{item.title}}</span>
</div>
</div>
</ng-template>
<ng-template #bookmarkTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickBookmarkSearchResult(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>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ms-1">
@if (searchTerm.toLowerCase().trim(); as st) {
@if (item.seriesName.toLowerCase().trim().indexOf(st) >= 0) {
<span>{{item.seriesName}}</span>
} @else {
<span [innerHTML]="item.localizedSeriesName"></span>
}
}
</div>
</div>
</ng-template>
<div [innerHTML]="item.name"></div>
<div class="text-light fst-italic">{{item.role | personRole}}</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">
<div>
<span>{{item.title}}</span>
<app-promoted-icon [promoted]="item.promoted"></app-promoted-icon>
</div>
<app-collection-owner [collection]="item"></app-collection-owner>
</div>
</div>
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToOther(FilterField.Genres, item.id)">
<div class="ms-1">
<div [innerHTML]="item.title"></div>
</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>
<app-promoted-icon [promoted]="item.promoted"></app-promoted-icon>
</div>
</div>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goToOther(FilterField.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)="goToOther(FilterField.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>
<!-- TODO: this needs the series name before the chapter issue -->
<span>{{item.titleName || item.range}}</span>
</div>
</div>
</ng-template>
<ng-template #chapterTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="clickChapterSearchResult(item)">
<div class="ms-1">
@if (item.files.length > 0) {
<app-series-format [format]="item.files?.[0].format"></app-series-format>
}
<!-- TODO: this needs the series name before the chapter issue -->
<span>{{item.titleName || item.range}}</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 #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>
{{t('no-data')}}
</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">{{t('scroll-to-top-alt')}}</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">{{t('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')">{{t('server-settings')}}</a>
<a ngbDropdownItem routerLink="/preferences/">{{t('settings')}}</a>
<a ngbDropdownItem routerLink="/all-filters/">{{t('all-filters')}}</a>
<a ngbDropdownItem href="https://wiki.kavitareader.com" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
<a ngbDropdownItem routerLink="/announcements/" *ngIf="accountService.hasAdminRole(user)">{{t('announcements')}}</a>
<a ngbDropdownItem (click)="logout()">{{t('logout')}}</a>
<ng-template #noResultsTemplate let-notFound>
{{t('no-data')}}
</ng-template>
</app-grouped-typeahead>
</div>
</div>
</div>
</ng-container>
</ng-container>
</div>
</nav>
}
</ul>
@if (!searchFocused) {
@if (backToTopNeeded) {
<div class="back-to-top">
<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">{{t('scroll-to-top-alt')}}</span>
</button>
</div>
}
@if (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="/settings" [fragment]="SettingsTabId.Account" class="dark-exempt btn btn-icon" [title]="t('settings')">
<i class="fa fa-cogs nav" aria-hidden="true"></i>
<span class="visually-hidden">{{t('settings')}}</span>
</a>
</div>
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right">
<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 ngbDropdownItem routerLink="/all-filters/">{{t('all-filters')}}</a>
<a ngbDropdownItem routerLink="/announcements/">{{t('announcements')}}</a>
<a ngbDropdownItem [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
<a ngbDropdownItem (click)="logout()">{{t('logout')}}</a>
</div>
</div>
}
}
</div>
</nav>
}
</ng-container>

View file

@ -45,6 +45,9 @@ import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {ProviderNamePipe} from "../../../_pipes/provider-name.pipe";
import {CollectionOwnerComponent} from "../../../collections/_components/collection-owner/collection-owner.component";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {WikiLink} from "../../../_models/wiki";
@Component({
selector: 'app-nav-header',
@ -52,14 +55,31 @@ import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/p
styleUrls: ['./nav-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, RouterLink, RouterLinkActive, NgOptimizedImage, GroupedTypeaheadComponent, ImageComponent,
imports: [RouterLink, RouterLinkActive, NgOptimizedImage, GroupedTypeaheadComponent, ImageComponent,
SeriesFormatComponent, EventsWidgetComponent, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
AsyncPipe, PersonRolePipe, SentenceCasePipe, TranslocoDirective, ProviderImagePipe, ProviderNamePipe, CollectionOwnerComponent, PromotedIconComponent]
})
export class NavHeaderComponent implements OnInit {
@ViewChild('search') searchViewRef!: any;
private readonly router = inject(Router);
private readonly scrollService = inject(ScrollService);
private readonly searchService = inject(SearchService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly accountService = inject(AccountService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly navService = inject(NavService);
protected readonly imageService = inject(ImageService);
protected readonly utilityService = inject(UtilityService);
protected readonly FilterField = FilterField;
protected readonly WikiLink = WikiLink;
protected readonly ScrobbleProvider = ScrobbleProvider;
protected readonly SettingsTabId = SettingsTabId;
protected readonly Breakpoint = Breakpoint;
@ViewChild('search') searchViewRef!: any;
isLoading = false;
debounceTime = 300;
@ -69,12 +89,8 @@ export class NavHeaderComponent implements OnInit {
backToTopNeeded = false;
searchFocused: boolean = false;
scrollElem: HTMLElement;
protected readonly FilterField = FilterField;
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,
private filterUtilityService: FilterUtilitiesService) {
constructor(@Inject(DOCUMENT) private document: Document) {
this.scrollElem = this.document.body;
}
@ -274,6 +290,4 @@ export class NavHeaderComponent implements OnInit {
this.navService.toggleSideNav();
}
protected readonly ScrobbleProvider = ScrobbleProvider;
}