Major Search Enhancements (#1238)

* Pull progress information for some of the recommended stuff.

* Fixed some redirection code from last PR

* Implemented the ability to search for files in the search and open the series directly.

* Fixed nav search bar expanding too much

* Fixed a bug in nav module not having router so some links broke

* Fixed an issue where with new localized series tag, merging could fail if the user had 2 series with the series and localized series.

Added extra error handling for tracking series parsed from disk.

* Fixed the slowness when typing in a typeahead by using auditTime vs debounceTime

* Removed some cleaning of Edition tags from the Parser. Only Omnibus and Uncensored will be ignored when cleaning titles, Full Color, Full Contact, etc will now stay in the title for Series name.

* Implemented ability to search against chapter's title (from epub or title in comicinfo). This should help users search for books in a series a lot easier.

* Restrict each search type to 15 records only to keep query performant and UI useful.

* Wrote some extra messaging on invite user flow around email.

* Messaging update
This commit is contained in:
Joseph Milazzo 2022-04-30 12:09:54 -05:00 committed by GitHub
parent a2d5ee18a0
commit d411ab03f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 244 additions and 49 deletions

View file

@ -1,6 +1,7 @@
import { MangaFormat } from './manga-format';
export interface MangaFile {
id: number;
filePath: string;
pages: number;
format: MangaFormat;

View file

@ -1,4 +1,6 @@
import { Chapter } from "../chapter";
import { Library } from "../library";
import { MangaFile } from "../manga-file";
import { SearchResult } from "../search-result";
import { Tag } from "../tag";
@ -10,6 +12,8 @@ export class SearchResultGroup {
persons: Array<Tag> = [];
genres: Array<Tag> = [];
tags: Array<Tag> = [];
files: Array<MangaFile> = [];
chapters: Array<Chapter> = [];
reset() {
this.libraries = [];
@ -19,5 +23,7 @@ export class SearchResultGroup {
this.persons = [];
this.genres = [];
this.tags = [];
this.files = [];
this.chapters = [];
}
}

View file

@ -1,17 +1,15 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
import { TypeaheadSettings } from '../typeahead/typeahead-settings';
import { ChapterMetadata } from '../_models/chapter-metadata';
import { Genre } from '../_models/genre';
import { AgeRating } from '../_models/metadata/age-rating';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { Language } from '../_models/metadata/language';
import { PublicationStatusDto } from '../_models/metadata/publication-status-dto';
import { Person, PersonRole } from '../_models/person';
import { Person } from '../_models/person';
import { Tag } from '../_models/tag';
@Injectable({

View file

@ -76,8 +76,12 @@ export class SeriesService {
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
}
getData(id: number) {
return of(id);
getSeriesForMangaFile(mangaFileId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId);
}
getSeriesForChapter(chapterId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId);
}
delete(seriesId: number) {

View file

@ -6,7 +6,8 @@
</div>
<div class="modal-body">
<p>
Invite a user to your server. Enter their email in and we will send them an email to create an account.
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can host your own
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
</p>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
@ -36,7 +37,7 @@
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externallyaccessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
@ -44,6 +45,10 @@
</div>
<div class="modal-footer">
<!-- <div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div> -->
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>

View file

@ -42,7 +42,7 @@
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<p class="accent" id="collection-info">Send anonymous usage and error information to Kavita's servers. This includes information on your browser, error reporting as well as OS and runtime version. We will use this information to prioritize features, bug fixes, and preformance tuning. Requires restart to take effect. See <a href="https://wiki.kavitareader.com/en/faq" target="_blank" referrerpolicy="no-refer">wiki</a> for what is collected.</p>
<div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div>
</div>

View file

@ -77,7 +77,7 @@ const routes: Routes = [
loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule)
},
{path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)},
{path: '**', loadChildren: () => import('../app/dashboard/dashboard.module').then(m => m.DashboardModule), pathMatch: 'full'},
{path: '**', pathMatch: 'full', redirectTo: 'libraries'},
];
@NgModule({

View file

@ -1,4 +1,7 @@
<!-- TODO: If there is nothing, then show a message -->
<ng-container *ngIf="onDeck$ | async as onDeck">
<app-carousel-reel [items]="onDeck" title="On Deck">
<ng-template #carouselItem let-item let-position="idx">

View file

@ -45,6 +45,8 @@ export class LibraryRecommendedComponent implements OnInit {
this.genre$.subscribe(genre => {
this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(map(p => p.result), shareReplay());
});
}

View file

@ -48,7 +48,7 @@
<ng-container *ngFor="let message of progressUpdates">
<li class="list-group-item dark-menu-item" *ngIf="message.progress === 'indeterminate' || message.progress === 'none'; else progressEvent">
<div class="h6 mb-1">{{message.title}}</div>
<div class="accent-text mb-1" *ngIf="message.subTitle !== ''">{{message.subTitle}}</div>
<div class="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>

View file

@ -85,8 +85,27 @@
</ul>
</ng-container>
<ng-container *ngIf="noResultsTemplate != undefined && searchTerm.length > 0 && !grouppedData.persons.length && !grouppedData.collections.length
&& !grouppedData.series.length && !grouppedData.persons.length && !grouppedData.tags.length && !grouppedData.genres.length && !grouppedData.libraries.length">
<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>

View file

@ -68,7 +68,7 @@ form {
}
&.focused {
width: 100%;
width: 99%;
border-color: var(--input-focused-border-color);
}

View file

@ -59,6 +59,8 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
@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;
@ -74,7 +76,11 @@ export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
}
get hasData() {
return this.grouppedData.persons.length || this.grouppedData.collections.length || this.grouppedData.series.length || this.grouppedData.persons.length || this.grouppedData.tags.length || this.grouppedData.genres.length;
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);
//return this.grouppedData.persons.length || this.grouppedData.collections.length || this.grouppedData.series.length || this.grouppedData.persons.length || this.grouppedData.tags.length || this.grouppedData.genres.length;
}

View file

@ -39,7 +39,7 @@
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
<div class="text-light fst-italic" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
</ng-template>
@ -84,7 +84,7 @@
<div class="ms-1">
<div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div>
<div class="text-light fst-italic">{{item.role | personRole}}</div>
</div>
</div>
</ng-template>
@ -97,6 +97,28 @@
</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>

View file

@ -3,7 +3,10 @@ import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { CollectionTag } from '../../_models/collection-tag';
import { Library } from '../../_models/library';
@ -48,7 +51,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService) { }
private scrollService: ScrollService, private seriesService: SeriesService) { }
ngOnInit(): void {}
@ -154,6 +157,24 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
this.router.navigate(['library', libraryId, 'series', seriesId]);
}
clickFileSearchResult(item: MangaFile) {
this.clearSearch();
this.seriesService.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.seriesService.getSeriesForChapter(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
})
}
clickLibraryResult(item: Library) {
this.router.navigate(['library', item.id]);
}

View file

@ -8,6 +8,7 @@ import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
@ -20,6 +21,7 @@ import { ReactiveFormsModule } from '@angular/forms';
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
NgbDropdownModule,
NgbPopoverModule,

View file

@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common';
import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, RendererStyleFlags2, TemplateRef, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Observable, of, ReplaySubject, Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { auditTime, distinctUntilChanged, filter, map, shareReplay, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { KEY_CODES } from '../shared/_services/utility.service';
import { SelectionCompareFn, TypeaheadSettings } from './typeahead-settings';
@ -206,26 +206,30 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
'typeahead': this.typeaheadControl
});
this.filteredOptions = this.typeaheadForm.get('typeahead')!.valueChanges
.pipe(
// Adjust input box to grow
tap(val => {
if (this.inputElem != null && this.inputElem.nativeElement != null) {
this.renderer2.setStyle(this.inputElem.nativeElement, 'width', 15 * ((this.typeaheadControl.value + '').length + 1) + 'px');
this.renderer2.setStyle(this.inputElem.nativeElement, 'width', 15 * (val.trim().length + 1) + 'px');
this.focusedIndex = 0;
}
}),
debounceTime(this.settings.debounce),
map(val => val.trim()),
auditTime(this.settings.debounce),
distinctUntilChanged(),
filter(val => {
// If minimum filter characters not met, do not filter
if (this.settings.minCharacters === 0) return true;
if (!val || val.trim().length < this.settings.minCharacters) {
if (!val || val.length < this.settings.minCharacters) {
return false;
}
return true;
}),
switchMap(val => {
this.isLoadingOptions = true;
let results: Observable<any[]>;
@ -241,12 +245,12 @@ export class TypeaheadComponent implements OnInit, OnDestroy {
tap((filteredOptions) => {
this.isLoadingOptions = false;
this.focusedIndex = 0;
//this.updateShowAddItem(filteredOptions);
setTimeout(() => {
this.updateShowAddItem(filteredOptions);
this.updateHighlight();
}, 10);
setTimeout(() => this.updateHighlight(), 20);
}),
shareReplay(),
takeUntil(this.onDestroy)