From 03112d3f8f7df82c4dda1a9d32d1be6581273b12 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Fri, 4 Feb 2022 08:28:49 -0800 Subject: [PATCH] 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 --- API/Controllers/CollectionController.cs | 2 +- API/Controllers/LibraryController.cs | 11 +- API/Controllers/OPDSController.cs | 43 ++++- API/DTOs/{ => Reader}/BookChapterItem.cs | 6 +- API/DTOs/{ => Search}/SearchResultDto.cs | 2 +- API/DTOs/Search/SearchResultGroupDto.cs | 21 +++ API/Data/Repositories/SeriesRepository.cs | 77 ++++++-- API/Helpers/AutoMapperProfiles.cs | 1 + UI/Web/package-lock.json | 11 +- UI/Web/package.json | 1 - .../app/_models/search/search-result-group.ts | 20 ++ UI/Web/src/app/_services/library.service.ts | 5 +- .../app/all-series/all-series.component.ts | 18 +- UI/Web/src/app/app.module.ts | 4 +- .../card-details-modal.component.html | 2 +- .../edit-series-modal.component.html | 2 +- .../cards/bookmark/bookmark.component.html | 3 +- .../cards/card-item/card-item.component.html | 11 +- .../chapter-metadata-detail.component.html | 2 +- .../cover-image-chooser.component.html | 4 +- .../collection-detail.component.html | 4 +- .../grouped-typeahead.component.html | 69 +++++++ .../grouped-typeahead.component.scss | 167 +++++++++++++++++ .../grouped-typeahead.component.ts | 175 ++++++++++++++++++ .../nav-events-toggle.component.scss | 5 + .../app/nav-header/nav-header.component.html | 154 ++++++++------- .../app/nav-header/nav-header.component.scss | 33 +++- .../app/nav-header/nav-header.component.ts | 73 +++++++- .../reading-list-detail.component.html | 3 +- .../series-detail.component.html | 3 +- .../series-detail.component.scss | 2 +- .../src/app/shared/image/image.component.html | 3 + .../src/app/shared/image/image.component.scss | 3 + .../src/app/shared/image/image.component.ts | 66 +++++++ UI/Web/src/app/shared/shared.module.ts | 7 +- UI/Web/src/assets/themes/dark.scss | 2 +- UI/Web/src/theme/_colors.scss | 1 + 37 files changed, 871 insertions(+), 145 deletions(-) rename API/DTOs/{ => Reader}/BookChapterItem.cs (95%) rename API/DTOs/{ => Search}/SearchResultDto.cs (94%) create mode 100644 API/DTOs/Search/SearchResultGroupDto.cs create mode 100644 UI/Web/src/app/_models/search/search-result-group.ts create mode 100644 UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html create mode 100644 UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss create mode 100644 UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts create mode 100644 UI/Web/src/app/shared/image/image.component.html create mode 100644 UI/Web/src/app/shared/image/image.component.scss create mode 100644 UI/Web/src/app/shared/image/image.component.ts diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 9f297273f..883c92bef 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -51,7 +51,7 @@ namespace API.Controllers public async Task> 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); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 9cdd06158..2c749d153 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -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>> Search(string queryString) + public async Task> 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); } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 9b7e87d62..6ddd67271 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -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() + { + 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() + { + CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/reading-list/{readingListDto.Id}"), + } + }); + } + + return CreateXmlResult(SerializeXml(feed)); } diff --git a/API/DTOs/BookChapterItem.cs b/API/DTOs/Reader/BookChapterItem.cs similarity index 95% rename from API/DTOs/BookChapterItem.cs rename to API/DTOs/Reader/BookChapterItem.cs index 68d1fce40..9db676cc5 100644 --- a/API/DTOs/BookChapterItem.cs +++ b/API/DTOs/Reader/BookChapterItem.cs @@ -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 /// public int Page { get; set; } - public ICollection Children { get; set; } + public ICollection Children { get; set; } } -} \ No newline at end of file +} diff --git a/API/DTOs/SearchResultDto.cs b/API/DTOs/Search/SearchResultDto.cs similarity index 94% rename from API/DTOs/SearchResultDto.cs rename to API/DTOs/Search/SearchResultDto.cs index 6d7ba9f58..328ff7a1f 100644 --- a/API/DTOs/SearchResultDto.cs +++ b/API/DTOs/Search/SearchResultDto.cs @@ -1,6 +1,6 @@ using API.Entities.Enums; -namespace API.DTOs +namespace API.DTOs.Search { public class SearchResultDto { diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs new file mode 100644 index 000000000..109b078ff --- /dev/null +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -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; + +/// +/// Represents all Search results for a query +/// +public class SearchResultGroupDto +{ + public IEnumerable Series { get; set; } + public IEnumerable Collections { get; set; } + public IEnumerable ReadingLists { get; set; } + public IEnumerable Persons { get; set; } + public IEnumerable Genres { get; set; } + public IEnumerable Tags { get; set; } + +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index a539185a7..4fa0690aa 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -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 /// /// Does not add user information like progress, ratings, etc. /// + /// + /// /// - /// Series name to search for + /// /// - Task> SearchSeries(int[] libraryIds, string searchQuery); + Task SearchSeries(int userId, bool isAdmin, int[] libraryIds, string searchQuery); Task> GetSeriesForLibraryIdAsync(int libraryId); Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task DeleteSeriesAsync(int seriesId); @@ -147,6 +150,7 @@ public class SeriesRepository : ISeriesRepository .CountAsync() > 1; } + public async Task> GetSeriesForLibraryIdAsync(int libraryId) { return await _context.Series @@ -267,9 +271,17 @@ public class SeriesRepository : ISeriesRepository }; } - public async Task> SearchSeries(int[] libraryIds, string searchQuery) + public async Task 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(_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(_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(_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(_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(_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(_mapper.ConfigurationProvider) + .ToListAsync(); + + return result; } - - - - - - - public async Task 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 DeleteSeriesAsync(int seriesId) { var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 0b3f89161..1c2426ae4 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -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; diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index c0c5b797a..68c606f64 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -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": { diff --git a/UI/Web/package.json b/UI/Web/package.json index c54eee713..26fb10c7f 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -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", diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts new file mode 100644 index 000000000..fb97b9e6e --- /dev/null +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -0,0 +1,20 @@ +import { SearchResult } from "../search-result"; +import { Tag } from "../tag"; + +export class SearchResultGroup { + series: Array = []; + collections: Array = []; + readingLists: Array = []; + persons: Array = []; + genres: Array = []; + tags: Array = []; + + reset() { + this.series = []; + this.collections = []; + this.readingLists = []; + this.persons = []; + this.genres = []; + this.tags = []; + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index f46643824..7aea516f0 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -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(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term)); + return this.httpClient.get(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term)); } } diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts index 236fd1267..11fa904f6 100644 --- a/UI/Web/src/app/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -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; diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index ea7a64fa0..c013a3de5 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -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, diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index 8bdaf5731..4bc78fb09 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -40,7 +40,7 @@
  • - +
    diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index ef3c2a3cc..a81b3bbff 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -104,7 +104,7 @@
    • - +
      Volume {{volume.name}}
      diff --git a/UI/Web/src/app/cards/bookmark/bookmark.component.html b/UI/Web/src/app/cards/bookmark/bookmark.component.html index 005d703a6..7ef42efa6 100644 --- a/UI/Web/src/app/cards/bookmark/bookmark.component.html +++ b/UI/Web/src/app/cards/bookmark/bookmark.component.html @@ -1,6 +1,5 @@
      - +
      diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 6f1163ef0..aafe5b88a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -1,9 +1,12 @@
      - - + + + + + + +

      diff --git a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html index 24d7b0aed..8c6172824 100644 --- a/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html +++ b/UI/Web/src/app/cards/chapter-metadata-detail/chapter-metadata-detail.component.html @@ -28,7 +28,7 @@
      • - +
        diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html index e9a772613..24a0703b9 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.html @@ -53,10 +53,10 @@
        - +
        - +
        diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html index 0e8516cf8..1d33472ea 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.html @@ -1,12 +1,12 @@
        - +

        + {{collectionTag.title}}

        diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html new file mode 100644 index 000000000..f9871106d --- /dev/null +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html @@ -0,0 +1,69 @@ +
        +
        +
        + +
        + Loading... +
        + +
        +
        + + +
        diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss new file mode 100644 index 000000000..b3e7d3021 --- /dev/null +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss @@ -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%; +} diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts new file mode 100644 index 000000000..385c0d87e --- /dev/null +++ b/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts @@ -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 = new EventEmitter(); + /** + * Emits when something is clicked/selected + */ + @Output() selected: EventEmitter = new EventEmitter(); + /** + * Emits an event when the field is cleared + */ + @Output() clearField: EventEmitter = new EventEmitter(); + /** + * Emits when a change in the search field looses/gains focus + */ + @Output() focusChanged: EventEmitter = new EventEmitter(); + + @ViewChild('input') inputElem!: ElementRef; + @ContentChild('itemTemplate') itemTemplate!: TemplateRef; + @ContentChild('seriesTemplate') seriesTemplate: TemplateRef | undefined; + @ContentChild('collectionTemplate') collectionTemplate: TemplateRef | undefined; + @ContentChild('tagTemplate') tagTemplate: TemplateRef | undefined; + @ContentChild('personTemplate') personTemplate: TemplateRef | undefined; + @ContentChild('noResultsTemplate') noResultsTemplate!: TemplateRef; + + + hasFocus: boolean = false; + isLoading: boolean = false; + typeaheadForm: FormGroup = new FormGroup({}); + + prevSearchTerm: string = ''; + + private onDestroy: Subject = 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(); + } + +} diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss index 7ab851b81..f19123aa7 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss @@ -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; diff --git a/UI/Web/src/app/nav-header/nav-header.component.html b/UI/Web/src/app/nav-header/nav-header.component.html index f12c58494..9258dff24 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav-header/nav-header.component.html @@ -10,44 +10,68 @@
        - - - - -
        -
        - -
        -
        - - - - - - in {{item.libraryName}} -
        + + + +
        +
        +
        - - - - No results found - +
        + + + + + + in {{item.libraryName}} +
        +
        +
        + + +
        +
        + +
        +
        + +
        +
        +
        + + +
        +
        + +
        +
        +
        + + +
        +
        + +
        +
        {{item.role | personRole}}
        +
        +
        +
        + + + No results found + + +
        @@ -55,35 +79,37 @@
      -
      - -
      + +
      + +
      - - - + + + + + + + - - -
      \ No newline at end of file diff --git a/UI/Web/src/app/nav-header/nav-header.component.scss b/UI/Web/src/app/nav-header/nav-header.component.scss index c0d170b06..5c5b3338c 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.scss +++ b/UI/Web/src/app/nav-header/nav-header.component.scss @@ -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; diff --git a/UI/Web/src/app/nav-header/nav-header.component.ts b/UI/Web/src/app/nav-header/nav-header.component.ts index b2f7896e1..4a8e459e1 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav-header/nav-header.component.ts @@ -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(); 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; + } + } diff --git a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html index bfe2e5d09..401735a96 100644 --- a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.html @@ -49,8 +49,7 @@
      - +
      {{formatTitle(item)}}  diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index e124d7379..668f7baa6 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -1,8 +1,7 @@
      - +
      diff --git a/UI/Web/src/app/series-detail/series-detail.component.scss b/UI/Web/src/app/series-detail/series-detail.component.scss index 3819e8b7a..ae7e20a70 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.scss +++ b/UI/Web/src/app/series-detail/series-detail.component.scss @@ -13,7 +13,7 @@ .poster { width: 100%; - max-width: 400px; + max-width: 300px; } diff --git a/UI/Web/src/app/shared/image/image.component.html b/UI/Web/src/app/shared/image/image.component.html new file mode 100644 index 000000000..42cc9fa9b --- /dev/null +++ b/UI/Web/src/app/shared/image/image.component.html @@ -0,0 +1,3 @@ + diff --git a/UI/Web/src/app/shared/image/image.component.scss b/UI/Web/src/app/shared/image/image.component.scss new file mode 100644 index 000000000..806188ca0 --- /dev/null +++ b/UI/Web/src/app/shared/image/image.component.scss @@ -0,0 +1,3 @@ +img { + width: 100%; +} \ No newline at end of file diff --git a/UI/Web/src/app/shared/image/image.component.ts b/UI/Web/src/app/shared/image/image.component.ts new file mode 100644 index 000000000..9cc8c8113 --- /dev/null +++ b/UI/Web/src/app/shared/image/image.component.ts @@ -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; + + 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); + } + + } + +} diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index d4e3ceedd..99965c696 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -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 { } diff --git a/UI/Web/src/assets/themes/dark.scss b/UI/Web/src/assets/themes/dark.scss index 55a56aa2e..8a5545fa8 100644 --- a/UI/Web/src/assets/themes/dark.scss +++ b/UI/Web/src/assets/themes/dark.scss @@ -172,7 +172,7 @@ } } .card { - background-color: $dark-bg-color; + background-color: $dark-card-color; color: $dark-text-color; border-color: $dark-form-border; } diff --git a/UI/Web/src/theme/_colors.scss b/UI/Web/src/theme/_colors.scss index 303d1d4f7..8500c1cce 100644 --- a/UI/Web/src/theme/_colors.scss +++ b/UI/Web/src/theme/_colors.scss @@ -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;