Filtering First Pass (#442)

# Added
- Added: Added "In Progress" view to see everything you are currently reading
- Added: Added the ability to filter series based on format from "In Progress", "Recently Added", "Library Detail" pages.
- Added: Added total items to the above pages to showcase total series within Kavita

==============================
* Added filtering to recently added

* Cleaned up the documentation on the APIs and removed params no longer needed.

* Implemented Filtering on library detail, in progress, and recently added for format. UI is non-final.

* Moved filtering to an expander panel

* Cleaned up filtering UI a bit

* Cleaned up some code and added titles on touched pages

* Fixed recently added not re-rendering page

* Removed commented out code

* Version bump

* Added an animation to the filtering section

* Stashing changes, needing to switch lazy loading libraries out due to current version not trigging on dom mutation events

* Finally fixed all the lazy loading issues and made it so pagination works without reloading the whole page.
This commit is contained in:
Joseph Milazzo 2021-07-27 18:39:53 -05:00 committed by GitHub
parent 434bcdae4c
commit b9f20f4d19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 422 additions and 99 deletions

View File

@ -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<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams)
[HttpPost]
public async Task<ActionResult<IEnumerable<Series>>> 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<ActionResult> 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<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded([FromQuery] UserParams userParams, int libraryId = 0)
[HttpPost("recently-added")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> 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<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(int libraryId = 0, int limit = 20)
[HttpPost("in-progress")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> 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<SeriesDto>());
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")]

View File

@ -0,0 +1,10 @@
using API.Entities.Enums;
namespace API.DTOs.Filtering
{
public class FilterDto
{
public MangaFormat? MangaFormat { get; init; } = null;
}
}

View File

@ -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<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams)
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
@ -120,7 +121,7 @@ namespace API.Data
private void SortSpecialChapters(IEnumerable<VolumeDto> 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
/// <param name="userId"></param>
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
/// <param name="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param>
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams)
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_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<SeriesDto>(_mapper.ConfigurationProvider)
@ -338,19 +340,22 @@ namespace API.Data
/// Returns Series that the user has some partial progress on
/// </summary>
/// <param name="userId"></param>
/// <param name="libraryId"></param>
/// <param name="limit"></param>
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
/// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns>
public async Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, int limit)
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.ToListAsync();
.AsNoTracking();
return retSeries.DistinctBy(s => s.Name).Take(limit);
return await PagedList<SeriesDto>.CreateAsync(retSeries, userParams.PageNumber, userParams.PageSize);
}
public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)

View File

@ -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
/// <param name="userId"></param>
/// <param name="userParams"></param>
/// <returns></returns>
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
/// <summary>
/// Does not add user information like progress, ratings, etc.
@ -57,10 +58,10 @@ namespace API.Interfaces
Task<byte[]> GetVolumeCoverImageAsync(int volumeId);
Task<byte[]> GetSeriesCoverImageAsync(int seriesId);
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, int limit);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams);
Task<PagedList<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
}
}
}

View File

@ -4,7 +4,7 @@
<TargetFramework>net5.0</TargetFramework>
<Company>kavitareader.com</Company>
<Product>Kavita</Product>
<AssemblyVersion>0.4.3.4</AssemblyVersion>
<AssemblyVersion>0.4.3.5</AssemblyVersion>
<NeutralLanguage>en</NeutralLanguage>
</PropertyGroup>

View File

@ -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": {

View File

@ -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",

View File

@ -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",

View File

@ -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
}
];

View File

@ -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;
}
}

View File

@ -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<PaginatedResult<Series[]>>(this.baseUrl + 'series?libraryId=' + libraryId, {observe: 'response', params}).pipe(
return this.httpClient.post<PaginatedResult<Series[]>>(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<void>(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<Series[]>(this.baseUrl + 'series/recently-added', {observe: 'response', params}).pipe(
map((response: any) => {
return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults);
return this.httpClient.post<Series[]>(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => {
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
})
);
}
getInProgress(libraryId: number = 0) {
return this.httpClient.get<Series[]>(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<Series[]>(this.baseUrl + 'series/in-progress?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => {
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
}));
}
@ -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;
}
}

View File

@ -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},
]

View File

@ -33,3 +33,4 @@ export class AppComponent implements OnInit {
}
}
}

View File

@ -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,
],

View File

@ -0,0 +1,14 @@
<ng-container>
<app-card-detail-layout header="In Progress"
[isLoading]="isLoading"
[items]="recentlyAdded"
[filters]="filters"
[pagination]="pagination"
(pageChange)="onPageChange($event)"
(applyFilter)="updateFilter($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"></app-series-card>
</ng-template>
</app-card-detail-layout>
</ng-container>

View File

@ -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<FilterItem> = 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');
}
}

View File

@ -3,6 +3,8 @@
[items]="series"
[actions]="actions"
[pagination]="pagination"
[filters]="filters"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">

View File

@ -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<Library>[] = [];
filters: Array<FilterItem> = 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');
}
}

View File

@ -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<CollectionTag>[] = [];
private readonly onDestroy = new Subject<void>();
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) {

View File

@ -3,6 +3,8 @@
[isLoading]="isLoading"
[items]="recentlyAdded"
[pagination]="pagination"
[filters]="filters"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)"
>
<ng-template #cardItem let-item let-position="idx">

View File

@ -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<FilterItem> = 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');
}
}

View File

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

View File

@ -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 => {

View File

@ -1,8 +1,31 @@
<div class="container-fluid" style="padding-top: 10px">
<h2><span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
</span>&nbsp;{{header}}</h2>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<div class="row no-gutters">
<div class="col mr-auto">
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
</span>&nbsp;{{header}}&nbsp;<span class="badge badge-primary badge-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</h2>
</div>
<button class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
<i class="fa fa-filter" aria-hidden="true"></i>
<span class="sr-only">Sort / Filter</span>
</button>
</div>
<div class="row no-gutters filter-section" #collapse="ngbCollapse" [(ngbCollapse)]="filteringCollapsed">
<div class="col">
<form class="ml-2" [formGroup]="filterForm">
<div class="form-group" *ngIf="filters.length > 0">
<label for="series-filter">Filter</label>
<select class="form-control" id="series-filter" formControlName="filter" (ngModelChange)="handleFilterChange($event)" style="max-width: 200px;">
<option [value]="i" *ngFor="let opt of filters; let i = index">{{opt.title}}</option>
</select>
</div>
</form>
</div>
</div>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<div class="row no-gutters">

View File

@ -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<any>[] = [];
/**
* A list of Filters which can filter the data of the page. If nothing is passed, the control will not show.
*/
@Input() filters: Array<FilterItem> = [];
@Input() trackByIdentity!: (index: number, item: any) => string;
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() applyFilter: EventEmitter<UpdateFilterEvent> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
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
});
}
}

View File

@ -1,7 +1,9 @@
<div class="card">
<div class="overlay" (click)="handleClick()">
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top" [lazyLoad]="imageUrl" [defaultImage]="imageSerivce.placeholderImage" alt="title">
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top" [lazyLoad]="imageSerivce.errorImage" alt="title">
<img *ngIf="total > 0 || supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top lazyload" [src]="imageSerivce.errorImage" [attr.data-src]="imageUrl"
aria-hidden="true" height="230px" width="158px">
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== (total -1)">
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
</div>

View File

@ -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<void>();
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() {

View File

@ -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; }