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;