diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index a0dba1e6c..09d4fd206 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.Data; using API.DTOs; +using API.DTOs.Filtering; using API.Entities; using API.Extensions; using API.Helpers; @@ -27,12 +28,12 @@ namespace API.Controllers _unitOfWork = unitOfWork; } - [HttpGet] - public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams) + [HttpPost] + public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams); + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest("Could not get series for library"); @@ -119,7 +120,7 @@ namespace API.Controllers return Ok(); } - [HttpPost] + [HttpPost("update")] public async Task UpdateSeries(UpdateSeriesDto updateSeries) { _logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name); @@ -147,12 +148,12 @@ namespace API.Controllers return BadRequest("There was an error with updating the series"); } - [HttpGet("recently-added")] - public async Task>> GetRecentlyAdded([FromQuery] UserParams userParams, int libraryId = 0) + [HttpPost("recently-added")] + public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var series = - await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams); + await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams, filterDto); // Apply progress/rating information (I can't work out how to do this in initial query) if (series == null) return BadRequest("Could not get series"); @@ -164,12 +165,11 @@ namespace API.Controllers return Ok(series); } - [HttpGet("in-progress")] - public async Task>> GetInProgress(int libraryId = 0, int limit = 20) + [HttpPost("in-progress")] + public async Task>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - if (user == null) return Ok(Array.Empty()); - return Ok(await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, limit)); + return Ok((await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto)).DistinctBy(s => s.Name)); } [Authorize(Policy = "RequireAdminRole")] diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs new file mode 100644 index 000000000..38a172016 --- /dev/null +++ b/API/DTOs/Filtering/FilterDto.cs @@ -0,0 +1,10 @@ +using API.Entities.Enums; + +namespace API.DTOs.Filtering +{ + public class FilterDto + { + public MangaFormat? MangaFormat { get; init; } = null; + + } +} diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index 098e0d97d..04d281870 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.DTOs; +using API.DTOs.Filtering; using API.Entities; using API.Extensions; using API.Helpers; @@ -75,10 +76,10 @@ namespace API.Data .ToListAsync(); } - public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams) + public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) { var query = _context.Series - .Where(s => s.LibraryId == libraryId) + .Where(s => s.LibraryId == libraryId && (filter.MangaFormat == null || s.Format == filter.MangaFormat)) .OrderBy(s => s.SortName) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking(); @@ -120,7 +121,7 @@ namespace API.Data private void SortSpecialChapters(IEnumerable volumes) { - foreach (var v in volumes.Where(vdto => vdto.Number == 0)) + foreach (var v in volumes.Where(vDto => vDto.Number == 0)) { v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList(); } @@ -302,8 +303,9 @@ namespace API.Data /// /// Library to restrict to, if 0, will apply to all libraries /// Contains pagination information + /// Optional filter on query /// - public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams) + public async Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter) { if (libraryId == 0) { @@ -315,7 +317,7 @@ namespace API.Data .ToList(); var allQuery = _context.Series - .Where(s => userLibraries.Contains(s.LibraryId)) + .Where(s => userLibraries.Contains(s.LibraryId) && (filter.MangaFormat == null || s.Format == filter.MangaFormat)) .AsNoTracking() .OrderByDescending(s => s.Created) .ProjectTo(_mapper.ConfigurationProvider) @@ -325,7 +327,7 @@ namespace API.Data } var query = _context.Series - .Where(s => s.LibraryId == libraryId) + .Where(s => s.LibraryId == libraryId && (filter.MangaFormat == null || s.Format == filter.MangaFormat)) .AsNoTracking() .OrderByDescending(s => s.Created) .ProjectTo(_mapper.ConfigurationProvider) @@ -338,19 +340,22 @@ namespace API.Data /// Returns Series that the user has some partial progress on /// /// - /// - /// + /// Library to restrict to, if 0, will apply to all libraries + /// Pagination information + /// Optional (default null) filter on query /// - public async Task> GetInProgress(int userId, int libraryId, int limit) + public async Task> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter) { + var series = _context.Series + .Where(s => filter.MangaFormat == null || s.Format == filter.MangaFormat) .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new { Series = s, PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id).Sum(s1 => s1.PagesRead), progress.AppUserId, LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id).Max(p => p.LastModified) - }); + }).AsNoTracking(); if (libraryId == 0) { var userLibraries = _context.Library @@ -371,14 +376,14 @@ namespace API.Data && s.PagesRead < s.Series.Pages && s.Series.LibraryId == libraryId); } - var retSeries = await series + + var retSeries = series .OrderByDescending(s => s.LastModified) .Select(s => s.Series) .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .ToListAsync(); + .AsNoTracking(); - return retSeries.DistinctBy(s => s.Name).Take(limit); + return await PagedList.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize); } public async Task GetSeriesMetadata(int seriesId) diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index 166ab05c3..bedbf8b56 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using API.DTOs; +using API.DTOs.Filtering; using API.Entities; using API.Helpers; @@ -21,7 +22,7 @@ namespace API.Interfaces /// /// /// - Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams); + Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter); /// /// Does not add user information like progress, ratings, etc. @@ -57,10 +58,10 @@ namespace API.Interfaces Task GetVolumeCoverImageAsync(int volumeId); Task GetSeriesCoverImageAsync(int seriesId); - Task> GetInProgress(int userId, int libraryId, int limit); - Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams); + Task> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter); + Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task> GetFilesForSeries(int seriesId); } -} \ No newline at end of file +} diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index b24530f56..02613b335 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,7 +4,7 @@ net5.0 kavitareader.com Kavita - 0.4.3.4 + 0.4.3.5 en diff --git a/UI/Web/angular.json b/UI/Web/angular.json index dd8270c0f..325878383 100644 --- a/UI/Web/angular.json +++ b/UI/Web/angular.json @@ -39,7 +39,10 @@ "src/styles.scss", "node_modules/@fortawesome/fontawesome-free/css/all.min.css" ], - "scripts": [] + "scripts": [ + "node_modules/lazysizes/lazysizes.min.js", + "node_modules/lazysizes/plugins/rias/ls.rias.min.js" + ] }, "configurations": { "production": { diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 64a49485a..3a78bd9de 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -29,6 +29,7 @@ "bootstrap": "^4.5.0", "bowser": "^2.11.0", "file-saver": "^2.0.5", + "lazysizes": "^5.3.2", "ng-lazyload-image": "^9.1.0", "ng-sidebar": "^9.4.2", "ngx-toastr": "^13.2.1", @@ -11745,6 +11746,11 @@ "node": ">= 8" } }, + "node_modules/lazysizes": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/lazysizes/-/lazysizes-5.3.2.tgz", + "integrity": "sha512-22UzWP+Vedi/sMeOr8O7FWimRVtiNJV2HCa+V8+peZOw6QbswN9k58VUhd7i6iK5bw5QkYrF01LJbeJe0PV8jg==" + }, "node_modules/less": { "version": "3.12.2", "resolved": "https://registry.npmjs.org/less/-/less-3.12.2.tgz", @@ -30076,6 +30082,11 @@ "integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==", "dev": true }, + "lazysizes": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/lazysizes/-/lazysizes-5.3.2.tgz", + "integrity": "sha512-22UzWP+Vedi/sMeOr8O7FWimRVtiNJV2HCa+V8+peZOw6QbswN9k58VUhd7i6iK5bw5QkYrF01LJbeJe0PV8jg==" + }, "less": { "version": "3.12.2", "resolved": "https://registry.npmjs.org/less/-/less-3.12.2.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index de53766f5..79f566f1b 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -36,6 +36,7 @@ "bootstrap": "^4.5.0", "bowser": "^2.11.0", "file-saver": "^2.0.5", + "lazysizes": "^5.3.2", "ng-lazyload-image": "^9.1.0", "ng-sidebar": "^9.4.2", "ngx-toastr": "^13.2.1", diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts new file mode 100644 index 000000000..a81cfe381 --- /dev/null +++ b/UI/Web/src/app/_models/series-filter.ts @@ -0,0 +1,39 @@ +import { MangaFormat } from "./manga-format"; + +export interface FilterItem { + title: string; + value: any; + selected: boolean; +} + +export interface SeriesFilter { + mangaFormat: MangaFormat | null; +} + +export const mangaFormatFilters = [ + { + title: 'Format: All', + value: null, + selected: false + }, + { + title: 'Format: Images', + value: MangaFormat.IMAGE, + selected: false + }, + { + title: 'Format: EPUB', + value: MangaFormat.EPUB, + selected: false + }, + { + title: 'Format: PDF', + value: MangaFormat.PDF, + selected: false + }, + { + title: 'Format: ARCHIVE', + value: MangaFormat.ARCHIVE, + selected: false + } +]; \ No newline at end of file diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 4135b34b9..377c2b0d9 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -38,4 +38,8 @@ export class ImageService { getChapterCoverImage(chapterId: number) { return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId; } + + updateErroredImage(event: any) { + event.target.src = this.placeholderImage; + } } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 033c34b35..c852a9e6d 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -6,8 +6,10 @@ import { environment } from 'src/environments/environment'; import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; import { InProgressChapter } from '../_models/in-progress-chapter'; +import { MangaFormat } from '../_models/manga-format'; import { PaginatedResult } from '../_models/pagination'; import { Series } from '../_models/series'; +import { SeriesFilter } from '../_models/series-filter'; import { SeriesMetadata } from '../_models/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; @@ -38,12 +40,12 @@ export class SeriesService { return paginatedVariable; } - getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number) { + getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + const data = this.createSeriesFilter(filter); - return this.httpClient.get>(this.baseUrl + 'series?libraryId=' + libraryId, {observe: 'response', params}).pipe( + return this.httpClient.post>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map((response: any) => { return this._cachePaginatedResults(response, this.paginatedResults); }) @@ -79,7 +81,7 @@ export class SeriesService { } updateSeries(model: any) { - return this.httpClient.post(this.baseUrl + 'series/', model); + return this.httpClient.post(this.baseUrl + 'series/update', model); } markRead(seriesId: number) { @@ -90,22 +92,27 @@ export class SeriesService { return this.httpClient.post(this.baseUrl + 'reader/mark-unread', {seriesId}); } - getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number) { + getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + const data = this.createSeriesFilter(filter); let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); - return this.httpClient.get(this.baseUrl + 'series/recently-added', {observe: 'response', params}).pipe( - map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults); + return this.httpClient.post(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( + map(response => { + return this._cachePaginatedResults(response, new PaginatedResult()); }) ); } - getInProgress(libraryId: number = 0) { - return this.httpClient.get(this.baseUrl + 'series/in-progress?libraryId=' + libraryId).pipe(map(series => { - series.forEach(s => s.coverImage = this.imageService.getSeriesCoverImage(s.id)); - return series; + getInProgress(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { + const data = this.createSeriesFilter(filter); + + let params = new HttpParams(); + params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post(this.baseUrl + 'series/in-progress?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( + map(response => { + return this._cachePaginatedResults(response, new PaginatedResult()); })); } @@ -160,4 +167,16 @@ export class SeriesService { } return params; } + + createSeriesFilter(filter?: SeriesFilter) { + const data: SeriesFilter = { + mangaFormat: null + }; + + if (filter) { + data.mangaFormat = filter.mangaFormat; + } + + return data; + } } diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 691410f7c..0c7fb6406 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -11,6 +11,7 @@ import { UserLoginComponent } from './user-login/user-login.component'; import { UserPreferencesComponent } from './user-preferences/user-preferences.component'; import { AuthGuard } from './_guards/auth.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard'; +import { InProgressComponent } from './in-progress/in-progress.component'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules @@ -44,6 +45,7 @@ const routes: Routes = [ canActivate: [AuthGuard], children: [ {path: 'recently-added', component: RecentlyAddedComponent}, + {path: 'in-progress', component: InProgressComponent}, {path: 'collections', component: AllCollectionsComponent}, {path: 'collections/:id', component: AllCollectionsComponent}, ] diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index bdec0812f..b406619f2 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -33,3 +33,4 @@ export class AppComponent implements OnInit { } } } + diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index b1d06d049..e447669bc 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -22,7 +22,6 @@ import { UserPreferencesComponent } from './user-preferences/user-preferences.co import { AutocompleteLibModule } from 'angular-ng-autocomplete'; import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; -import { LazyLoadImageModule} from 'ng-lazyload-image'; import { CarouselModule } from './carousel/carousel.module'; import { NgxSliderModule } from '@angular-slider/ngx-slider'; @@ -39,6 +38,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { LibraryCardComponent } from './library-card/library-card.component'; import { SeriesCardComponent } from './series-card/series-card.component'; +import { InProgressComponent } from './in-progress/in-progress.component'; let sentryProviders: any[] = []; @@ -103,7 +103,8 @@ if (environment.production) { EditCollectionTagsComponent, RecentlyAddedComponent, LibraryCardComponent, - SeriesCardComponent + SeriesCardComponent, + InProgressComponent ], imports: [ HttpClientModule, @@ -120,7 +121,6 @@ if (environment.production) { NgbAccordionModule, // User Preferences NgxSliderModule, // User Preference NgbPaginationModule, - LazyLoadImageModule, SharedModule, CarouselModule, TypeaheadModule, @@ -136,7 +136,6 @@ if (environment.production) { providers: [ {provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, - //{ provide: LAZYLOAD_IMAGE_HOOKS, useClass: ScrollHooks } // Great, but causes flashing after modals close Title, ...sentryProviders, ], diff --git a/UI/Web/src/app/in-progress/in-progress.component.html b/UI/Web/src/app/in-progress/in-progress.component.html new file mode 100644 index 000000000..ebae6ff8b --- /dev/null +++ b/UI/Web/src/app/in-progress/in-progress.component.html @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/UI/Web/src/app/in-progress/in-progress.component.scss b/UI/Web/src/app/in-progress/in-progress.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/in-progress/in-progress.component.ts b/UI/Web/src/app/in-progress/in-progress.component.ts new file mode 100644 index 000000000..21321c015 --- /dev/null +++ b/UI/Web/src/app/in-progress/in-progress.component.ts @@ -0,0 +1,76 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { Router, ActivatedRoute } from '@angular/router'; +import { take } from 'rxjs/operators'; +import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component'; +import { Pagination } from '../_models/pagination'; +import { Series } from '../_models/series'; +import { FilterItem, SeriesFilter, mangaFormatFilters } from '../_models/series-filter'; +import { SeriesService } from '../_services/series.service'; + +@Component({ + selector: 'app-in-progress', + templateUrl: './in-progress.component.html', + styleUrls: ['./in-progress.component.scss'] +}) +export class InProgressComponent implements OnInit { + + isLoading: boolean = true; + recentlyAdded: Series[] = []; + pagination!: Pagination; + libraryId!: number; + filters: Array = mangaFormatFilters; + filter: SeriesFilter = { + mangaFormat: null + }; + + constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title) { + this.router.routeReuseStrategy.shouldReuseRoute = () => false; + this.titleService.setTitle('Kavita - In Progress'); + if (this.pagination === undefined || this.pagination === null) { + this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + } + this.loadPage(); + } + + ngOnInit() {} + + seriesClicked(series: Series) { + this.router.navigate(['library', this.libraryId, 'series', series.id]); + } + + onPageChange(pagination: Pagination) { + window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); + this.loadPage(); + } + + updateFilter(data: UpdateFilterEvent) { + this.filter.mangaFormat = data.filterItem.value; + if (this.pagination !== undefined && this.pagination !== null) { + this.pagination.currentPage = 1; + this.onPageChange(this.pagination); + } else { + this.loadPage(); + } + } + + loadPage() { + const page = this.getPage(); + if (page != null) { + this.pagination.currentPage = parseInt(page, 10); + } + this.isLoading = true; + this.seriesService.getInProgress(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { + this.recentlyAdded = series.result; + this.pagination = series.pagination; + this.isLoading = false; + window.scrollTo(0, 0); + }); + } + + getPage() { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('page'); + } + +} diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index df19322f7..652e0c849 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -3,6 +3,8 @@ [items]="series" [actions]="actions" [pagination]="pagination" + [filters]="filters" + (applyFilter)="updateFilter($event)" (pageChange)="onPageChange($event)" > diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 770c127dd..711ae4b73 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -2,9 +2,11 @@ import { Component, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs/operators'; +import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component'; import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; +import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter'; import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { LibraryService } from '../_services/library.service'; @@ -23,9 +25,14 @@ export class LibraryDetailComponent implements OnInit { loadingSeries = false; pagination!: Pagination; actions: ActionItem[] = []; + filters: Array = mangaFormatFilters; + filter: SeriesFilter = { + mangaFormat: null + }; constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, - private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, private actionService: ActionService) { + private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, + private actionService: ActionService) { const routeId = this.route.snapshot.paramMap.get('id'); if (routeId === null) { this.router.navigateByUrl('/libraries'); @@ -36,12 +43,14 @@ export class LibraryDetailComponent implements OnInit { this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => { this.libraryName = names[this.libraryId]; this.titleService.setTitle('Kavita - ' + this.libraryName); - }) - this.loadPage(); + }); this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); + this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + this.loadPage(); } ngOnInit(): void { + } handleAction(action: Action, library: Library) { @@ -61,17 +70,24 @@ export class LibraryDetailComponent implements OnInit { } } - loadPage() { - if (this.pagination == undefined || this.pagination == null) { - this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + updateFilter(data: UpdateFilterEvent) { + this.filter.mangaFormat = data.filterItem.value; + if (this.pagination !== undefined && this.pagination !== null) { + this.pagination.currentPage = 1; + this.onPageChange(this.pagination); + } else { + this.loadPage(); } + } - const page = this.route.snapshot.queryParamMap.get('page'); + loadPage() { + const page = this.getPage(); if (page != null) { this.pagination.currentPage = parseInt(page, 10); } this.loadingSeries = true; - this.seriesService.getSeriesForLibrary(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage).pipe(take(1)).subscribe(series => { + + this.seriesService.getSeriesForLibrary(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { this.series = series.result; this.pagination = series.pagination; this.loadingSeries = false; @@ -80,13 +96,19 @@ export class LibraryDetailComponent implements OnInit { } onPageChange(pagination: Pagination) { - this.router.navigate(['library', this.libraryId], {replaceUrl: true, queryParamsHandling: 'merge', queryParams: {page: this.pagination.currentPage} }); + window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); + this.loadPage(); } seriesClicked(series: Series) { this.router.navigate(['library', this.libraryId, 'series', series.id]); } - trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}`; + trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`; + + getPage() { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('page'); + } } diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index 4d613b9b6..ea51ed429 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -1,8 +1,9 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { take } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; import { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component'; import { CollectionTag } from '../_models/collection-tag'; import { InProgressChapter } from '../_models/in-progress-chapter'; @@ -20,7 +21,7 @@ import { SeriesService } from '../_services/series.service'; templateUrl: './library.component.html', styleUrls: ['./library.component.scss'] }) -export class LibraryComponent implements OnInit { +export class LibraryComponent implements OnInit, OnDestroy { user: User | undefined; libraries: Library[] = []; @@ -33,6 +34,8 @@ export class LibraryComponent implements OnInit { collectionTags: CollectionTag[] = []; collectionTagActions: ActionItem[] = []; + private readonly onDestroy = new Subject(); + seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`; constructor(public accountService: AccountService, private libraryService: LibraryService, @@ -57,13 +60,18 @@ export class LibraryComponent implements OnInit { this.reloadSeries(); } + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + reloadSeries() { - this.seriesService.getRecentlyAdded(0, 0, 20).subscribe(updatedSeries => { + this.seriesService.getRecentlyAdded(0, 0, 20).pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { this.recentlyAdded = updatedSeries.result; }); - this.seriesService.getInProgress().subscribe((updatedSeries) => { - this.inProgress = updatedSeries; + this.seriesService.getInProgress().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.inProgress = updatedSeries.result; }); this.reloadTags(); @@ -78,15 +86,15 @@ export class LibraryComponent implements OnInit { return; } - this.seriesService.getInProgress().subscribe((updatedSeries) => { - this.inProgress = updatedSeries; + this.seriesService.getInProgress().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.inProgress = updatedSeries.result; }); this.reloadTags(); } reloadTags() { - this.collectionService.allTags().subscribe(tags => { + this.collectionService.allTags().pipe(takeUntil(this.onDestroy)).subscribe(tags => { this.collectionTags = tags; }); } @@ -96,7 +104,9 @@ export class LibraryComponent implements OnInit { this.router.navigate(['collections']); } else if (sectionTitle.toLowerCase() === 'recently added') { this.router.navigate(['recently-added']); - } + } else if (sectionTitle.toLowerCase() === 'in progress') { + this.router.navigate(['in-progress']); + } } loadCollection(item: CollectionTag) { diff --git a/UI/Web/src/app/recently-added/recently-added.component.html b/UI/Web/src/app/recently-added/recently-added.component.html index 29ec803fb..b9bfbec74 100644 --- a/UI/Web/src/app/recently-added/recently-added.component.html +++ b/UI/Web/src/app/recently-added/recently-added.component.html @@ -3,6 +3,8 @@ [isLoading]="isLoading" [items]="recentlyAdded" [pagination]="pagination" + [filters]="filters" + (applyFilter)="updateFilter($event)" (pageChange)="onPageChange($event)" > diff --git a/UI/Web/src/app/recently-added/recently-added.component.ts b/UI/Web/src/app/recently-added/recently-added.component.ts index 8df490b6f..ab92d24ae 100644 --- a/UI/Web/src/app/recently-added/recently-added.component.ts +++ b/UI/Web/src/app/recently-added/recently-added.component.ts @@ -1,7 +1,11 @@ import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; +import { take } from 'rxjs/operators'; +import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; +import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter'; import { SeriesService } from '../_services/series.service'; /** @@ -19,36 +23,57 @@ export class RecentlyAddedComponent implements OnInit { pagination!: Pagination; libraryId!: number; - constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService) { - this.router.routeReuseStrategy.shouldReuseRoute = () => false; - } + filters: Array = mangaFormatFilters; + filter: SeriesFilter = { + mangaFormat: null + }; - ngOnInit() { + constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title) { + this.router.routeReuseStrategy.shouldReuseRoute = () => false; + this.titleService.setTitle('Kavita - Recently Added'); + if (this.pagination === undefined || this.pagination === null) { + this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; + } this.loadPage(); } + ngOnInit() {} + seriesClicked(series: Series) { this.router.navigate(['library', this.libraryId, 'series', series.id]); } onPageChange(pagination: Pagination) { - this.router.navigate(['recently-added'], {replaceUrl: true, queryParamsHandling: 'merge', queryParams: {page: this.pagination.currentPage} }); + window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage); + this.loadPage(); + } + + updateFilter(data: UpdateFilterEvent) { + this.filter.mangaFormat = data.filterItem.value; + if (this.pagination !== undefined && this.pagination !== null) { + this.pagination.currentPage = 1; + this.onPageChange(this.pagination); + } else { + this.loadPage(); + } } loadPage() { - const page = this.route.snapshot.queryParamMap.get('page'); - if (page != null) { - if (this.pagination === undefined || this.pagination === null) { - this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; - } - this.pagination.currentPage = parseInt(page, 10); - } - this.isLoading = true; - this.seriesService.getRecentlyAdded(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage).subscribe(series => { - this.recentlyAdded = series.result; - this.pagination = series.pagination; - this.isLoading = false; - window.scrollTo(0, 0); - }); + const page = this.getPage(); + if (page != null) { + this.pagination.currentPage = parseInt(page, 10); } + this.isLoading = true; + this.seriesService.getRecentlyAdded(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => { + this.recentlyAdded = series.result; + this.pagination = series.pagination; + this.isLoading = false; + window.scrollTo(0, 0); + }); + } + + getPage() { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('page'); + } } 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 774757d67..9f27e980d 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -1,7 +1,8 @@
-
- +
+
diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 1eb89bda8..f57d7fd8a 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -86,7 +86,8 @@ export class SeriesDetailComponent implements OnInit { private accountService: AccountService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, private libraryService: LibraryService, private confirmService: ConfirmService, private titleService: Title, - private downloadService: DownloadService, private actionService: ActionService) { + private downloadService: DownloadService, private actionService: ActionService, + public imageSerivce: ImageService) { ratingConfig.max = 5; this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { diff --git a/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html b/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html index 0630fa4da..0238a1a97 100644 --- a/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html +++ b/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.html @@ -1,8 +1,31 @@
-

- -  {{header}}

- +
+
+

+ + +  {{header}} {{pagination.totalItems}} +

+
+ + +
+
+
+
+
+ + +
+
+
+
+
diff --git a/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.ts b/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.ts index 7f283e82a..82c4a74e0 100644 --- a/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.ts +++ b/UI/Web/src/app/shared/card-detail-layout/card-detail-layout.component.ts @@ -1,9 +1,33 @@ import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core'; +import { FormGroup, FormControl } from '@angular/forms'; import { Pagination } from 'src/app/_models/pagination'; +import { FilterItem } from 'src/app/_models/series-filter'; import { ActionItem } from 'src/app/_services/action-factory.service'; const FILTER_PAG_REGEX = /[^0-9]/g; +export enum FilterAction { + /** + * If an option is selected on a multi select component + */ + Added = 0, + /** + * If an option is unselected on a multi select component + */ + Removed = 1, + /** + * If an option is selected on a single select component + */ + Selected = 2 +} + +export interface UpdateFilterEvent { + filterItem: FilterItem; + action: FilterAction; +} + +const ANIMATION_SPEED = 300; + @Component({ selector: 'app-card-detail-layout', templateUrl: './card-detail-layout.component.html', @@ -15,12 +39,29 @@ export class CardDetailLayoutComponent implements OnInit { @Input() isLoading: boolean = false; @Input() items: any[] = []; @Input() pagination!: Pagination; + /** + * Any actions to exist on the header for the parent collection (library, collection) + */ @Input() actions: ActionItem[] = []; + /** + * A list of Filters which can filter the data of the page. If nothing is passed, the control will not show. + */ + @Input() filters: Array = []; @Input() trackByIdentity!: (index: number, item: any) => string; @Output() itemClicked: EventEmitter = new EventEmitter(); @Output() pageChange: EventEmitter = new EventEmitter(); + @Output() applyFilter: EventEmitter = new EventEmitter(); @ContentChild('cardItem') itemTemplate!: TemplateRef; + + filterForm: FormGroup = new FormGroup({ + filter: new FormControl(0, []), + }); + + /** + * Controls the visiblity of extended controls that sit below the main header. + */ + filteringCollapsed: boolean = true; constructor() { } @@ -47,4 +88,11 @@ export class CardDetailLayoutComponent implements OnInit { } } + handleFilterChange(index: string) { + this.applyFilter.emit({ + filterItem: this.filters[parseInt(index, 10)], + action: FilterAction.Selected + }); + } + } diff --git a/UI/Web/src/app/shared/card-item/card-item.component.html b/UI/Web/src/app/shared/card-item/card-item.component.html index ffb22adb0..8e36c4589 100644 --- a/UI/Web/src/app/shared/card-item/card-item.component.html +++ b/UI/Web/src/app/shared/card-item/card-item.component.html @@ -1,7 +1,9 @@
- title - title + +

diff --git a/UI/Web/src/app/shared/card-item/card-item.component.ts b/UI/Web/src/app/shared/card-item/card-item.component.ts index dc631681b..0bb884535 100644 --- a/UI/Web/src/app/shared/card-item/card-item.component.ts +++ b/UI/Web/src/app/shared/card-item/card-item.component.ts @@ -10,6 +10,8 @@ import { ActionItem } from 'src/app/_services/action-factory.service'; import { ImageService } from 'src/app/_services/image.service'; import { LibraryService } from 'src/app/_services/library.service'; import { UtilityService } from '../_services/utility.service'; +// import 'lazysizes'; +// import 'lazysizes/plugins/attrchange/ls.attrchange'; @Component({ selector: 'app-card-item', @@ -38,8 +40,7 @@ export class CardItemComponent implements OnInit, OnDestroy { private readonly onDestroy = new Subject(); - constructor(public imageSerivce: ImageService, private libraryService: LibraryService, public utilityService: UtilityService) { - } + constructor(public imageSerivce: ImageService, private libraryService: LibraryService, public utilityService: UtilityService) {} ngOnInit(): void { if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { @@ -59,6 +60,7 @@ export class CardItemComponent implements OnInit, OnDestroy { ngOnDestroy() { this.onDestroy.next(); + this.onDestroy.complete(); } handleClick() { diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index 7b2d3ab16..270c9b8d1 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -10,7 +10,7 @@ // Custom animation for ng-lazyload-image img.ng-lazyloaded { - animation: fadein .5s; + //animation: fadein .5s; // I think it might look better without animation } @keyframes fadein { from { opacity: 0; }