New Search (#1029)

* Implemented a basic version of enhanced search where we can return multiple types of entities in one go. Current unoptimized version is twice as expensive as normal search, but under NFR. Currently 200ms max.

* Worked in some basic code for grouped typeahead search component. Keyboard navigation is working.

* Most of the code is in place for the typeahead. Needs css work and some accessibility work.

* Hooked up filtering into all-series. Added debouncing on search, clear input field now works. Some optimizations related to memory cleanup

* Added ability to define a custom placeholder

* Hooked in noResults template and logic

* Fixed a duplicate issue in Collection tag searching and commented out old code. OPDS still needs some updates.

* Don't trigger inputChanged when reopening/clicking on input.

* Added Reading list to OPDS search

* Added a new image component so all the images can be lazyloaded without logic duplication

* Added a maxWidth/Height on the image component

* Search css update

* cursor fixes

* card changes

- fixing border radius on cards
- adding bottom card color

* Expose intenral state of if the search component has focus

* Adjusted the accessibility to not use complex keys and just use tab instead since this is a search, not a typeahead

* Cleaned up dead code, removed angular-ng-complete library as it's no longer used.

* Fixes for mobile search

* Merged code

* Fixed a bad merge and some nav bar styling

* Cleaned up the focus code for nav bar

* Removed focusIndex and just use hover state. Fixed clicking on items

* fixing overlay overlap issue

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-02-04 08:28:49 -08:00 committed by GitHub
parent 60b717ea1d
commit 03112d3f8f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 871 additions and 145 deletions

View File

@ -51,7 +51,7 @@ namespace API.Controllers
public async Task<IEnumerable<CollectionTagDto>> SearchTags(string queryString)
{
queryString ??= "";
queryString = queryString.Replace(@"%", "");
queryString = queryString.Replace(@"%", string.Empty);
if (queryString.Length == 0) return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -224,17 +225,19 @@ namespace API.Controllers
}
[HttpGet("search")]
public async Task<ActionResult<IEnumerable<SearchResultDto>>> Search(string queryString)
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
{
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty);
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), queryString);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
return Ok(series);
}

View File

@ -10,6 +10,7 @@ using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.OPDS;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -424,6 +425,8 @@ public class OpdsController : BaseApiController
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (string.IsNullOrEmpty(query))
{
return BadRequest("You must pass a query parameter");
@ -434,15 +437,51 @@ public class OpdsController : BaseApiController
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
var series = await _unitOfWork.SeriesRepository.SearchSeries(libraries.Select(l => l.Id).ToArray(), query);
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin, libraries.Select(l => l.Id).ToArray(), query);
var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey);
SetFeedId(feed, "search-series");
foreach (var seriesDto in series)
foreach (var seriesDto in series.Series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
}
foreach (var collection in series.Collections)
{
feed.Entries.Add(new FeedEntry()
{
Id = collection.Id.ToString(),
Title = collection.Title,
Summary = collection.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
Prefix + $"{apiKey}/collections/{collection.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"/api/image/collection-cover?collectionId={collection.Id}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"/api/image/collection-cover?collectionId={collection.Id}")
}
});
}
foreach (var readingListDto in series.ReadingLists)
{
feed.Entries.Add(new FeedEntry()
{
Id = readingListDto.Id.ToString(),
Title = readingListDto.Title,
Summary = readingListDto.Summary,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"),
}
});
}
return CreateXmlResult(SerializeXml(feed));
}

View File

@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace API.DTOs
namespace API.DTOs.Reader
{
public class BookChapterItem
{
@ -16,6 +16,6 @@ namespace API.DTOs
/// Page Number to load for the chapter
/// </summary>
public int Page { get; set; }
public ICollection<BookChapterItem> Children { get; set; }
public ICollection<BookChapterItem> Children { get; set; }
}
}
}

View File

@ -1,6 +1,6 @@
using API.Entities.Enums;
namespace API.DTOs
namespace API.DTOs.Search
{
public class SearchResultDto
{

View File

@ -0,0 +1,21 @@
using System.Collections.Generic;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.ReadingLists;
using API.Entities;
namespace API.DTOs.Search;
/// <summary>
/// Represents all Search results for a query
/// </summary>
public class SearchResultGroupDto
{
public IEnumerable<SearchResultDto> Series { get; set; }
public IEnumerable<CollectionTagDto> Collections { get; set; }
public IEnumerable<ReadingListDto> ReadingLists { get; set; }
public IEnumerable<PersonDto> Persons { get; set; }
public IEnumerable<GenreTagDto> Genres { get; set; }
public IEnumerable<TagDto> Tags { get; set; }
}

View File

@ -3,12 +3,13 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Migrations;
using API.Data.Scanner;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
@ -60,10 +61,12 @@ public interface ISeriesRepository
/// <summary>
/// Does not add user information like progress, ratings, etc.
/// </summary>
/// <param name="userId"></param>
/// <param name="isAdmin"></param>
/// <param name="libraryIds"></param>
/// <param name="searchQuery">Series name to search for</param>
/// <param name="searchQuery"></param>
/// <returns></returns>
Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery);
Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery);
Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId);
Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId);
Task<bool> DeleteSeriesAsync(int seriesId);
@ -147,6 +150,7 @@ public class SeriesRepository : ISeriesRepository
.CountAsync() > 1;
}
public async Task<IEnumerable<Series>> GetSeriesForLibraryIdAsync(int libraryId)
{
return await _context.Series
@ -267,9 +271,17 @@ public class SeriesRepository : ISeriesRepository
};
}
public async Task<IEnumerable<SearchResultDto>> SearchSeries(int[] libraryIds, string searchQuery)
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery)
{
return await _context.Series
var result = new SearchResultGroupDto();
var seriesIds = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Id)
.ToList();
result.Series = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")
@ -277,17 +289,55 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Library)
.OrderBy(s => s.SortName)
.AsNoTracking()
.AsSplitQuery()
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.ReadingLists = await _context.ReadingList
.Where(rl => rl.AppUserId == userId || rl.Promoted)
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.AsSplitQuery()
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Collections = await _context.CollectionTag
.Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%")
|| EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%"))
.Where(s => s.Promoted || isAdmin)
.OrderBy(s => s.Title)
.AsNoTracking()
.OrderBy(c => c.NormalizedTitle)
.ProjectTo<CollectionTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Persons = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.People.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Genres = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.OrderBy(t => t.Title)
.Distinct()
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Tags = await _context.SeriesMetadata
.Where(sm => seriesIds.Contains(sm.SeriesId))
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.OrderBy(t => t.Title)
.Distinct()
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
return result;
}
public async Task<SeriesDto> GetSeriesDtoByIdAsync(int seriesId, int userId)
{
var series = await _context.Series.Where(x => x.Id == seriesId)
@ -300,9 +350,6 @@ public class SeriesRepository : ISeriesRepository
return seriesList[0];
}
public async Task<bool> DeleteSeriesAsync(int seriesId)
{
var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync();

View File

@ -5,6 +5,7 @@ using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;

View File

@ -3255,11 +3255,6 @@
"integrity": "sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=",
"dev": true
},
"angular-ng-autocomplete": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/angular-ng-autocomplete/-/angular-ng-autocomplete-2.0.5.tgz",
"integrity": "sha512-mYALrzwc5eoFR5xz/diup5GDsxqXp3L707P4CkiBl5l01fKej0nyIDTQ+xXtZUK3spXIyfuOX0ypa9wTrgCP5A=="
},
"ansi-colors": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
@ -12408,7 +12403,8 @@
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"strip-ansi": {
@ -12613,7 +12609,8 @@
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"dev": true
},
"ansi-styles": {

View File

@ -32,7 +32,6 @@
"@ngx-lite/nav-drawer": "^0.4.6",
"@ngx-lite/util": "0.0.0",
"@types/file-saver": "^2.0.1",
"angular-ng-autocomplete": "^2.0.5",
"bootstrap": "^4.5.0",
"bowser": "^2.11.0",
"file-saver": "^2.0.5",

View File

@ -0,0 +1,20 @@
import { SearchResult } from "../search-result";
import { Tag } from "../tag";
export class SearchResultGroup {
series: Array<SearchResult> = [];
collections: Array<Tag> = [];
readingLists: Array<Tag> = [];
persons: Array<Tag> = [];
genres: Array<Tag> = [];
tags: Array<Tag> = [];
reset() {
this.series = [];
this.collections = [];
this.readingLists = [];
this.persons = [];
this.genres = [];
this.tags = [];
}
}

View File

@ -5,6 +5,7 @@ import { map, take } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Library, LibraryType } from '../_models/library';
import { SearchResult } from '../_models/search-result';
import { SearchResultGroup } from '../_models/search/search-result-group';
@Injectable({
@ -106,9 +107,9 @@ export class LibraryService {
search(term: string) {
if (term === '') {
return of([]);
return of(new SearchResultGroup());
}
return this.httpClient.get<SearchResult[]>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
}
}

View File

@ -1,11 +1,11 @@
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { take, debounceTime, takeUntil } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { FilterSettings } from '../cards/card-detail-layout/card-detail-layout.component';
import { KEY_CODES } from '../shared/_services/utility.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { Library } from '../_models/library';
import { Pagination } from '../_models/pagination';
@ -70,14 +70,15 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
constructor(private router: Router, private seriesService: SeriesService,
private titleService: Title, private actionService: ActionService,
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) {
public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
private utilityService: UtilityService, private route: ActivatedRoute) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Kavita - All Series');
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
this.loadPage();
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter());
}
ngOnInit(): void {
@ -116,11 +117,10 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
}
loadPage() {
const page = this.getPage();
if (page != null) {
this.pagination.currentPage = parseInt(page, 10);
// The filter is out of sync with the presets from typeaheads on first load but syncs afterwards
if (this.filter == undefined) {
this.filter = this.seriesService.createSeriesFilter();
}
this.loadingSeries = true;
this.seriesService.getAllSeries(this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
this.series = series.result;

View File

@ -18,7 +18,6 @@ import { SharedModule } from './shared/shared.module';
import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { CarouselModule } from './carousel/carousel.module';
@ -37,6 +36,7 @@ import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-m
import { AllSeriesComponent } from './all-series/all-series.component';
import { PublicationStatusPipe } from './publication-status.pipe';
import { RegistrationModule } from './registration/registration.module';
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
@NgModule({
@ -57,6 +57,7 @@ import { RegistrationModule } from './registration/registration.module';
PublicationStatusPipe,
SeriesMetadataDetailComponent,
AllSeriesComponent,
GroupedTypeaheadComponent,
],
imports: [
HttpClientModule,
@ -67,7 +68,6 @@ import { RegistrationModule } from './registration/registration.module';
FormsModule, // EditCollection Modal
NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav
NgbPopoverModule, // Nav Events toggle
NgbRatingModule, // Series Detail
NgbNavModule,

View File

@ -40,7 +40,7 @@
<ul class="list-unstyled">
<li class="media my-4" *ngFor="let chapter of chapters">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
<img class="mr-3" style="width: 74px" [src]="chapter.coverImage">
<app-image class="mr-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="media-body">
<h5 class="mt-0 mb-1">

View File

@ -104,7 +104,7 @@
</div>
<ul class="list-unstyled" *ngIf="!isLoadingVolumes">
<li class="media my-4" *ngFor="let volume of seriesVolumes">
<img class="mr-3" style="width: 74px;" src="{{imageService.getVolumeCoverImage(volume.id)}}" >
<app-image class="mr-3" style="width: 74px;" width="74px" [imageUrl]="imageService.getVolumeCoverImage(volume.id)"></app-image>
<div class="media-body">
<h5 class="mt-0 mb-1">Volume {{volume.name}}</h5>
<div>

View File

@ -1,6 +1,5 @@
<div class="card" *ngIf="bookmark != undefined">
<img class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="170px">
<app-image height="230px" width="170px" [imageUrl]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"></app-image>
<div class="card-body" *ngIf="bookmark.page >= 0">
<div class="header-row">

View File

@ -1,9 +1,12 @@
<div class="card {{selected ? 'selected-highlight' : ''}}">
<div class="overlay" (click)="handleClick($event)">
<img *ngIf="total > 0 || supressArchiveWarning" class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="img-top lazyload" [src]="imageService.errorImage" [attr.data-src]="imageUrl"
aria-hidden="true" height="230px" width="158px">
<ng-container *ngIf="total > 0 || supressArchiveWarning">
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageUrl"></app-image>
</ng-container>
<ng-container *ngIf="total === 0 && !supressArchiveWarning">
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image>
</ng-container>
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>

View File

@ -28,7 +28,7 @@
<ul class="list-unstyled" >
<li class="media my-4">
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{libraryType !== LibraryType.Comic ? 'Chapter ' : 'Issue #'}} {{chapter.number}}">
<img class="mr-3" style="width: 74px" [src]="chapter.coverImage">
<app-image class="mr-3" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</a>
<div class="media-body">
<h5 class="mt-0 mb-1">

View File

@ -53,10 +53,10 @@
<div class="row no-gutters chooser" style="padding-top: 10px">
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
<img class="card-img-top" [src]="url" aria-hidden="true" height="230px" width="158px" (error)="imageService.updateErroredImage($event)">
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url"></app-image>
</div>
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
<img class="card-img-top" title="Reset Cover Image" [src]="imageService.resetCoverImage" aria-hidden="true" height="230px" width="158px">
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
</div>
</div>

View File

@ -1,12 +1,12 @@
<div class="container-fluid" *ngIf="collectionTag !== undefined" style="padding-top: 10px">
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6">
<img class="poster lazyload" [src]="imageService.placeholderImage" [attr.data-src]="tagImage"
(error)="imageService.updateErroredImage($event)" aria-hidden="true">
<app-image class="poster" maxWidth="481px" [imageUrl]="tagImage"></app-image>
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row no-gutters">
<h2>
{{collectionTag.title}}
</h2>
</div>

View File

@ -0,0 +1,69 @@
<form [formGroup]="typeaheadForm" class="grouped-typeahead">
<div class="typeahead-input" [ngClass]="{'focused': hasFocus == true}" (click)="onInputFocus($event)">
<div>
<input #input [id]="id" type="text" autocomplete="off" formControlName="typeahead" [placeholder]="placeholder"
aria-haspopup="listbox" aria-owns="dropdown" aria-expanded="hasFocus && (grouppedData.persons.length || grouppedData.collections.length || grouppedData.series.length || grouppedData.persons.length || grouppedData.tags.length || grouppedData.genres.length)"
aria-autocomplete="list" (focusout)="close($event)" (focus)="open($event)"
>
<div class="spinner-border spinner-border-sm" role="status" *ngIf="isLoading">
<span class="sr-only">Loading...</span>
</div>
<button type="button" class="close" aria-label="Close" (click)="resetField()">
<span aria-hidden="true">&times;</span>
</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="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>Tags</h5></li>
<ul class="list-group results">
<li *ngFor="let option of grouppedData.persons; let index = index;" (click)="handleResultlick(option)" tabindex="0"
class="list-group-item" role="option">
<ng-container [ngTemplateOutlet]="personTemplate" [ngTemplateOutletContext]="{ $implicit: option, idx: index }"></ng-container>
</li>
</ul>
</ng-container>
<ng-container *ngIf="noResultsTemplate != undefined && searchTerm.length > 0 && !grouppedData.persons.length && !grouppedData.collections.length && !grouppedData.series.length && !grouppedData.persons.length && !grouppedData.tags.length && !grouppedData.genres.length">
<ul class="list-group results">
<li class="list-group-item">
<ng-container [ngTemplateOutlet]="noResultsTemplate"></ng-container>
</li>
</ul>
</ng-container>
</ul>
</div>
</form>

View File

@ -0,0 +1,167 @@
@use "../../theme/colors";
form {
max-height: 38px;
}
input {
width: 15px;
opacity: 1;
position: relative;
left: 4px;
border: none;
}
.search-result img {
width: 100% !important;
}
.typeahead-input {
border: 1px solid #ccc;
padding: 0px 6px;
display: inline-block;
overflow: hidden;
position: relative;
z-index: 1;
box-sizing: border-box;
box-shadow: none;
border-radius: 4px;
cursor: text;
background-color: #fff;
min-height: 38px;
transition-property: all;
transition-duration: 0.3s;
display: block;
.close {
cursor: pointer;
position: absolute;
top: 7px;
right: 10px;
}
input {
outline: 0 !important;
border-radius: .28571429rem;
display: inline-block !important;
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;
}
input:focus-visible {
width: calc(100vw - 400px);
}
input:empty {
padding-top: 6px !important;
}
}
.typeahead-input.focused {
width: 100%;
}
/* small devices (phones, 650px and down) */
@media only screen and (max-width:650px) {
.typeahead-input {
width: 120px;
}
input {
width: 100%
}
input:focus-visible {
width: 100% !important;
}
}
::ng-deep .bg-dark .typeahead-input {
color: #efefef;
background-color: colors.$dark-bg-color;
}
// Causes bleedover
::ng-deep .bg-dark .dropdown .list-group-item.hover {
background-color: colors.$dark-hover-color;
}
.dropdown {
width: 100vw;
height: calc(100vh - 57px); //header offset
background: rgba(0,0,0,0.5);
position: fixed;
justify-content: center;
left: 0;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
margin-top: 10px;
}
.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;
//background-color: colors.$dark-bg-color;
}
.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;
}
.list-group-item {
cursor: pointer;
}
.section-header {
background: colors.$dark-item-accent-bg;
cursor: default;
}
.section-header:hover {
background-color: colors.$dark-item-accent-bg !important;
}
.spinner-border {
position: absolute;
right: 10px;
margin: auto;
cursor: pointer;
top: 30%;
}

View File

@ -0,0 +1,175 @@
import { DOCUMENT } from '@angular/common';
import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { KEY_CODES } from '../shared/_services/utility.service';
import { SearchResultGroup } from '../_models/search/search-result-group';
@Component({
selector: 'app-grouped-typeahead',
templateUrl: './grouped-typeahead.component.html',
styleUrls: ['./grouped-typeahead.component.scss']
})
export class GroupedTypeaheadComponent implements OnInit, OnDestroy {
/**
* Unique id to tie with a label element
*/
@Input() id: string = 'grouped-typeahead';
/**
* Minimum number of characters in input to trigger a search
*/
@Input() minQueryLength: number = 0;
/**
* Initial value of the search model
*/
@Input() initialValue: string = '';
@Input() grouppedData: SearchResultGroup = new SearchResultGroup();
/**
* Placeholder for the input
*/
@Input() placeholder: string = '';
/**
* Number of milliseconds after typing before triggering inputChanged for data fetching
*/
@Input() debounceTime: number = 200;
/**
* Emits when the input changes from user interaction
*/
@Output() inputChanged: EventEmitter<string> = new EventEmitter();
/**
* Emits when something is clicked/selected
*/
@Output() selected: EventEmitter<any> = new EventEmitter();
/**
* Emits an event when the field is cleared
*/
@Output() clearField: EventEmitter<void> = new EventEmitter();
/**
* Emits when a change in the search field looses/gains focus
*/
@Output() focusChanged: EventEmitter<boolean> = new EventEmitter();
@ViewChild('input') inputElem!: ElementRef<HTMLInputElement>;
@ContentChild('itemTemplate') itemTemplate!: TemplateRef<any>;
@ContentChild('seriesTemplate') seriesTemplate: TemplateRef<any> | undefined;
@ContentChild('collectionTemplate') collectionTemplate: TemplateRef<any> | undefined;
@ContentChild('tagTemplate') tagTemplate: TemplateRef<any> | undefined;
@ContentChild('personTemplate') personTemplate: TemplateRef<any> | undefined;
@ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef<any>;
hasFocus: boolean = false;
isLoading: boolean = false;
typeaheadForm: FormGroup = new FormGroup({});
prevSearchTerm: string = '';
private onDestroy: Subject<void> = new Subject();
get searchTerm() {
return this.typeaheadForm.get('typeahead')?.value || '';
}
get hasData() {
return this.grouppedData.persons.length || this.grouppedData.collections.length || this.grouppedData.series.length || this.grouppedData.persons.length || this.grouppedData.tags.length || this.grouppedData.genres.length;
}
constructor(private renderer2: Renderer2, @Inject(DOCUMENT) private document: Document) { }
@HostListener('window:click', ['$event'])
handleDocumentClick(event: any) {
this.close();
}
@HostListener('window:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) {
if (!this.hasFocus) { return; }
switch(event.key) {
case KEY_CODES.ESC_KEY:
this.close();
event.stopPropagation();
break;
default:
break;
}
}
ngOnInit(): void {
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
this.typeaheadForm.valueChanges.pipe(debounceTime(this.debounceTime), takeUntil(this.onDestroy)).subscribe(change => {
const value = this.typeaheadForm.get('typeahead')?.value;
if (value != undefined && value.length >= this.minQueryLength) {
if (this.prevSearchTerm === value) return;
this.inputChanged.emit(value);
this.prevSearchTerm = value;
}
});
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
onInputFocus(event: any) {
if (event) {
event.stopPropagation();
event.preventDefault();
}
if (this.inputElem) {
// hack: To prevent multiple typeaheads from being open at once, click document then trigger the focus
this.document.querySelector('body')?.click();
this.inputElem.nativeElement.focus();
this.open();
}
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.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.clearField.emit();
}
close(event?: FocusEvent) {
if (event) {
// If the user is tabbing out of the input field, check if there are results first before closing
if (this.hasData) {
return;
}
}
this.hasFocus = false;
this.focusChanged.emit(this.hasFocus);
}
open(event?: FocusEvent) {
this.hasFocus = true;
this.focusChanged.emit(this.hasFocus);
}
public clear() {
this.resetField();
}
}

View File

@ -1,5 +1,10 @@
@use "../../theme/colors";
.btn:focus, .btn:hover {
box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1); // TODO: Used in nav as well, move to dark for btn-icon focus
}
.small-spinner {
width: 1rem;
height: 1rem;

View File

@ -10,44 +10,68 @@
<div class="form-group" style="margin-bottom: 0px;">
<label for="nav-search" class="sr-only">Search series</label>
<div class="ng-autocomplete">
<ng-autocomplete
#search
id="nav-search"
[classList]="['ng-autocomplete']"
[data]="searchResults"
searchKeyword="name"
placeholder="Search Series"
[initialValue]=""
[focusFirst]="true"
[minQueryLength]="2"
(selected)='clickSearchResult($event)'
(inputChanged)='onChangeSearch($event)'
[isLoading]="isLoading"
[customFilter]="customFilter"
[debounceTime]="debounceTime"
[itemTemplate]="itemTemplate"
[notFoundTemplate]="notFoundTemplate">
</ng-autocomplete>
<ng-template #itemTemplate let-item>
<div style="display: flex;padding: 5px;">
<div style="width: 24px" class="mr-1">
<img class="mr-3 search-result" src="{{imageService.getSeriesCoverImage(item.seriesId)}}">
</div>
<div class="ml-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName" [innerHTML]="item.name"></span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<span class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</span>
</div>
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
<ng-template #seriesTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickSearchResult(item)">
<div style="width: 24px" class="mr-1">
<app-image class="mr-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
</div>
</ng-template>
<ng-template #notFoundTemplate let-notFound>
No results found
</ng-template>
<div class="ml-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName" [innerHTML]="item.name"></span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<span class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</span>
</div>
</div>
</ng-template>
<ng-template #collectionTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goToPerson(item.role, item.id)">
<div style="width: 24px" class="mr-1">
<app-image class="mr-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
</div>
<div class="ml-1">
<span *ngIf="item.title.toLowerCase().trim().indexOf(searchTerm) >= 0" [innerHTML]="item.title"></span>
</div>
</div>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tag', item.id)">
<div class="ml-1">
<span *ngIf="item.title.toLowerCase().trim().indexOf(searchTerm) >= 0" [innerHTML]="item.title"></span>
</div>
</div>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div class="ml-1">
<div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div>
</div>
</div>
</ng-template>
<ng-template #noResultsTemplate let-notFound>
No results found
</ng-template>
</app-grouped-typeahead>
</div>
</div>
</fieldset>
@ -55,35 +79,37 @@
</div>
</ul>
<div class="back-to-top">
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
<i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i>
<span class="sr-only">Scroll to Top</span>
</button>
</div>
<ng-container *ngIf="!searchFocused">
<div class="back-to-top">
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
<i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i>
<span class="sr-only">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 pr-2">
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt" style="padding: 5px">
<i class="fa fa-cogs" aria-hidden="true" style="color: white"></i>
<span class="sr-only">Server Settings</span>
</a>
</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 pr-2">
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt btn btn-icon">
<i class="fa fa-cogs" aria-hidden="true" style="color: white"></i>
<span class="sr-only">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>
{{user.username | sentenceCase}}
</button>
<div ngbDropdownMenu>
<a ngbDropdownItem routerLink="/preferences/">Settings</a>
<a ngbDropdownItem (click)="logout()">Logout</a>
</div>
</div>
</ng-container>
</ng-container>
<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>
{{user.username | sentenceCase}}
</button>
<div ngbDropdownMenu>
<a ngbDropdownItem routerLink="/preferences/">Settings</a>
<a ngbDropdownItem (click)="logout()">Logout</a>
</div>
</div>
</div>
</nav>

View File

@ -3,10 +3,25 @@
$primary-color: white;
$bg-color: rgb(22, 27, 34);
.btn:focus, .btn:hover {
box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1);
}
.navbar {
background-color: $bg-color;
}
/* small devices (phones, 650px and down) */
@media only screen and (max-width:650px) { //370
.navbar-nav {
width: 34%;
}
}
.nav-item.dropdown {
position: unset;
}
.navbar-brand {
font-family: "Spartan", sans-serif;
font-weight: bold;
@ -28,7 +43,6 @@ $bg-color: rgb(22, 27, 34);
.ng-autocomplete {
margin-bottom: 0px;
max-width: 400px;
}
.primary-text {
@ -41,18 +55,21 @@ $bg-color: rgb(22, 27, 34);
margin-top: 5px;
}
.form-inline .form-group {
width: 100%;
}
@media (min-width: 576px) {
.form-inline .form-group {
width: 100%;
}
}
@include media-breakpoint-down(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {
.ng-autocomplete {
width: 100%; // 232px
}
}
/* Extra small devices (phones, 300px and down) */
@media only screen and (max-width: 300px) { //370
.ng-autocomplete {
max-width: 120px;
}
}
.scroll-to-top:hover {
animation: MoveUpDown 1s linear infinite;

View File

@ -5,7 +5,9 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { isTemplateSpan } from 'typescript';
import { ScrollService } from '../scroll.service';
import { PersonRole } from '../_models/person';
import { SearchResult } from '../_models/search-result';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { AccountService } from '../_services/account.service';
import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service';
@ -23,7 +25,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
isLoading = false;
debounceTime = 300;
imageStyles = {width: '24px', 'margin-top': '5px'};
searchResults: SearchResult[] = [];
searchResults: SearchResultGroup = new SearchResultGroup();
searchTerm = '';
customFilter: (items: SearchResult[], query: string) => SearchResult[] = (items: SearchResult[], query: string) => {
const normalizedQuery = query.trim().toLowerCase();
@ -38,6 +40,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
backToTopNeeded = false;
searchFocused: boolean = false;
private readonly onDestroy = new Subject<void>();
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
@ -78,27 +81,81 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
}
moveFocus() {
document.getElementById('content')?.focus();
this.document.getElementById('content')?.focus();
}
onChangeSearch(val: string) {
this.isLoading = true;
this.searchTerm = val.trim();
this.libraryService.search(val).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchResults = results;
this.isLoading = false;
}, err => {
this.searchResults = [];
this.searchResults.reset();
this.isLoading = false;
this.searchTerm = '';
});
}
goTo(queryParamName: string, filter: any) {
let params: any = {};
params[queryParamName] = filter;
params['page'] = 1;
this.router.navigate(['all-series'], {queryParams: params});
}
goToPerson(role: PersonRole, filter: any) {
// TODO: Move this to utility service
switch(role) {
case PersonRole.Artist:
this.goTo('artist', filter);
break;
case PersonRole.Character:
this.goTo('character', filter);
break;
case PersonRole.Colorist:
this.goTo('colorist', filter);
break;
case PersonRole.Editor:
this.goTo('editor', filter);
break;
case PersonRole.Inker:
this.goTo('inker', filter);
break;
case PersonRole.CoverArtist:
this.goTo('coverArtist', filter);
break;
case PersonRole.Inker:
this.goTo('inker', filter);
break;
case PersonRole.Letterer:
this.goTo('letterer', filter);
break;
case PersonRole.Penciller:
this.goTo('penciller', filter);
break;
case PersonRole.Publisher:
this.goTo('publisher', filter);
break;
case PersonRole.Translator:
this.goTo('translator', filter);
break;
}
}
clearSearch() {
this.searchResults = new SearchResultGroup();
}
clickSearchResult(item: SearchResult) {
console.log('Click occured');
const libraryId = item.libraryId;
const seriesId = item.seriesId;
this.searchViewRef.clear();
this.searchResults = [];
this.searchResults.reset();
this.searchTerm = '';
this.router.navigate(['library', libraryId, 'series', seriesId]);
}
@ -110,5 +167,11 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
});
}
focusUpdate(searchFocused: boolean) {
console.log('search has focus', searchFocused);
this.searchFocused = searchFocused
return searchFocused;
}
}

View File

@ -49,8 +49,7 @@
<app-dragable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)" [accessibilityMode]="accessibilityMode">
<ng-template #draggableItem let-item let-position="idx">
<div class="media" style="width: 100%;">
<img width="74px" style="width: 74px;" class="img-top lazyload mr-3" [src]="imageService.placeholderImage" [attr.data-src]="imageService.getChapterCoverImage(item.chapterId)"
(error)="imageService.updateErroredImage($event)">
<app-image width="74px" class="img-top mr-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<div class="media-body">
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}&nbsp;
<span class="badge badge-primary badge-pill">

View File

@ -1,8 +1,7 @@
<div class="container-fluid" *ngIf="series !== undefined" style="padding-top: 10px">
<div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6">
<img class="poster lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="seriesImage"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true">
<app-image class="poster" maxWidth="300px" [imageUrl]="seriesImage"></app-image>
</div>
<div class="col-md-10 col-xs-8 col-sm-6">
<div class="row no-gutters">

View File

@ -13,7 +13,7 @@
.poster {
width: 100%;
max-width: 400px;
max-width: 300px;
}

View File

@ -0,0 +1,3 @@
<img #img class="lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageService.updateErroredImage($event)"
aria-hidden="true">

View File

@ -0,0 +1,3 @@
img {
width: 100%;
}

View File

@ -0,0 +1,66 @@
import { Component, ElementRef, Input, OnChanges, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { ImageService } from 'src/app/_services/image.service';
/**
* This is used for images with placeholder fallback.
*/
@Component({
selector: 'app-image',
templateUrl: './image.component.html',
styleUrls: ['./image.component.scss']
})
export class ImageComponent implements OnChanges {
/**
* Source url to load image
*/
@Input() imageUrl!: string;
/**
* Width of the image. If not defined, will not be applied
*/
@Input() width: string = '';
/**
* Height of the image. If not defined, will not be applied
*/
@Input() height: string = '';
/**
* Max Width of the image. If not defined, will not be applied
*/
@Input() maxWidth: string = '';
/**
* Max Height of the image. If not defined, will not be applied
*/
@Input() maxHeight: string = '';
/**
* Border Radius of the image. If not defined, will not be applied
*/
@Input() borderRadius: string = '';
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
constructor(public imageService: ImageService, private renderer: Renderer2) { }
ngOnChanges(changes: SimpleChanges): void {
if (this.width != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'width', this.width);
}
if (this.height != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'height', this.height);
}
if (this.maxWidth != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'max-width', this.maxWidth);
}
if (this.maxHeight != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'max-height', this.maxHeight);
}
if (this.borderRadius != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'border-radius', this.borderRadius);
}
}
}

View File

@ -17,6 +17,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
import { SentenceCasePipe } from './sentence-case.pipe';
import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { BadgeExpanderComponent } from './badge-expander/badge-expander.component';
import { ImageComponent } from './image/image.component';
@NgModule({
declarations: [
@ -32,7 +33,8 @@ import { BadgeExpanderComponent } from './badge-expander/badge-expander.componen
CircularLoaderComponent,
SentenceCasePipe,
PersonBadgeComponent,
BadgeExpanderComponent
BadgeExpanderComponent,
ImageComponent
],
imports: [
CommonModule,
@ -55,7 +57,8 @@ import { BadgeExpanderComponent } from './badge-expander/badge-expander.componen
TagBadgeComponent,
CircularLoaderComponent,
PersonBadgeComponent,
BadgeExpanderComponent
BadgeExpanderComponent,
ImageComponent
],
})
export class SharedModule { }

View File

@ -172,7 +172,7 @@
}
}
.card {
background-color: $dark-bg-color;
background-color: $dark-card-color;
color: $dark-text-color;
border-color: $dark-form-border;
}

View File

@ -2,6 +2,7 @@ $primary-color: #4ac694; //(74,198,148)
$error-color: #ff4136; // #bb2929 good color for contrast rating
$dark-bg-color: #343a40;
$dark-card-color: rgba(22,27,34,0.5);
$dark-primary-color: rgba(74, 198, 148, 0.9);
$dark-text-color: #efefef;
$dark-hover-color: #4ac694;