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 System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering;
using API.Entities; using API.Entities;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
@ -27,12 +28,12 @@ namespace API.Controllers
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
} }
[HttpGet] [HttpPost]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams) public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series = 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) // 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"); if (series == null) return BadRequest("Could not get series for library");
@ -119,7 +120,7 @@ namespace API.Controllers
return Ok(); return Ok();
} }
[HttpPost] [HttpPost("update")]
public async Task<ActionResult> UpdateSeries(UpdateSeriesDto updateSeries) public async Task<ActionResult> UpdateSeries(UpdateSeriesDto updateSeries)
{ {
_logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name); _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"); return BadRequest("There was an error with updating the series");
} }
[HttpGet("recently-added")] [HttpPost("recently-added")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded([FromQuery] UserParams userParams, int libraryId = 0) 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 user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series = 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) // 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"); if (series == null) return BadRequest("Could not get series");
@ -164,12 +165,11 @@ namespace API.Controllers
return Ok(series); return Ok(series);
} }
[HttpGet("in-progress")] [HttpPost("in-progress")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(int libraryId = 0, int limit = 20) public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); 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, userParams, filterDto)).DistinctBy(s => s.Name));
return Ok(await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, limit));
} }
[Authorize(Policy = "RequireAdminRole")] [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 System.Threading.Tasks;
using API.Comparators; using API.Comparators;
using API.DTOs; using API.DTOs;
using API.DTOs.Filtering;
using API.Entities; using API.Entities;
using API.Extensions; using API.Extensions;
using API.Helpers; using API.Helpers;
@ -75,10 +76,10 @@ namespace API.Data
.ToListAsync(); .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 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) .OrderBy(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking(); .AsNoTracking();
@ -120,7 +121,7 @@ namespace API.Data
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes) 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(); v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList();
} }
@ -302,8 +303,9 @@ namespace API.Data
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</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="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param>
/// <returns></returns> /// <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) if (libraryId == 0)
{ {
@ -315,7 +317,7 @@ namespace API.Data
.ToList(); .ToList();
var allQuery = _context.Series var allQuery = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId)) .Where(s => userLibraries.Contains(s.LibraryId) && (filter.MangaFormat == null || s.Format == filter.MangaFormat))
.AsNoTracking() .AsNoTracking()
.OrderByDescending(s => s.Created) .OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -325,7 +327,7 @@ namespace API.Data
} }
var query = _context.Series var query = _context.Series
.Where(s => s.LibraryId == libraryId) .Where(s => s.LibraryId == libraryId && (filter.MangaFormat == null || s.Format == filter.MangaFormat))
.AsNoTracking() .AsNoTracking()
.OrderByDescending(s => s.Created) .OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
@ -338,19 +340,22 @@ namespace API.Data
/// Returns Series that the user has some partial progress on /// Returns Series that the user has some partial progress on
/// </summary> /// </summary>
/// <param name="userId"></param> /// <param name="userId"></param>
/// <param name="libraryId"></param> /// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
/// <param name="limit"></param> /// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns> /// <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 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 .Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new
{ {
Series = s, Series = s,
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id).Sum(s1 => s1.PagesRead), PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id).Sum(s1 => s1.PagesRead),
progress.AppUserId, progress.AppUserId,
LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id).Max(p => p.LastModified) LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id).Max(p => p.LastModified)
}); }).AsNoTracking();
if (libraryId == 0) if (libraryId == 0)
{ {
var userLibraries = _context.Library var userLibraries = _context.Library
@ -371,14 +376,14 @@ namespace API.Data
&& s.PagesRead < s.Series.Pages && s.PagesRead < s.Series.Pages
&& s.Series.LibraryId == libraryId); && s.Series.LibraryId == libraryId);
} }
var retSeries = await series
var retSeries = series
.OrderByDescending(s => s.LastModified) .OrderByDescending(s => s.LastModified)
.Select(s => s.Series) .Select(s => s.Series)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider) .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking() .AsNoTracking();
.ToListAsync();
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) public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)

View File

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

View File

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

View File

@ -39,7 +39,10 @@
"src/styles.scss", "src/styles.scss",
"node_modules/@fortawesome/fontawesome-free/css/all.min.css" "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": { "configurations": {
"production": { "production": {

View File

@ -29,6 +29,7 @@
"bootstrap": "^4.5.0", "bootstrap": "^4.5.0",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-lazyload-image": "^9.1.0", "ng-lazyload-image": "^9.1.0",
"ng-sidebar": "^9.4.2", "ng-sidebar": "^9.4.2",
"ngx-toastr": "^13.2.1", "ngx-toastr": "^13.2.1",
@ -11745,6 +11746,11 @@
"node": ">= 8" "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": { "node_modules/less": {
"version": "3.12.2", "version": "3.12.2",
"resolved": "https://registry.npmjs.org/less/-/less-3.12.2.tgz", "resolved": "https://registry.npmjs.org/less/-/less-3.12.2.tgz",
@ -30076,6 +30082,11 @@
"integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==", "integrity": "sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==",
"dev": true "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": { "less": {
"version": "3.12.2", "version": "3.12.2",
"resolved": "https://registry.npmjs.org/less/-/less-3.12.2.tgz", "resolved": "https://registry.npmjs.org/less/-/less-3.12.2.tgz",

View File

@ -36,6 +36,7 @@
"bootstrap": "^4.5.0", "bootstrap": "^4.5.0",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-lazyload-image": "^9.1.0", "ng-lazyload-image": "^9.1.0",
"ng-sidebar": "^9.4.2", "ng-sidebar": "^9.4.2",
"ngx-toastr": "^13.2.1", "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) { getChapterCoverImage(chapterId: number) {
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId; 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 { Chapter } from '../_models/chapter';
import { CollectionTag } from '../_models/collection-tag'; import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter'; import { InProgressChapter } from '../_models/in-progress-chapter';
import { MangaFormat } from '../_models/manga-format';
import { PaginatedResult } from '../_models/pagination'; import { PaginatedResult } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { SeriesFilter } from '../_models/series-filter';
import { SeriesMetadata } from '../_models/series-metadata'; import { SeriesMetadata } from '../_models/series-metadata';
import { Volume } from '../_models/volume'; import { Volume } from '../_models/volume';
import { ImageService } from './image.service'; import { ImageService } from './image.service';
@ -38,12 +40,12 @@ export class SeriesService {
return paginatedVariable; return paginatedVariable;
} }
getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number) { getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
let params = new HttpParams(); let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage); 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) => { map((response: any) => {
return this._cachePaginatedResults(response, this.paginatedResults); return this._cachePaginatedResults(response, this.paginatedResults);
}) })
@ -79,7 +81,7 @@ export class SeriesService {
} }
updateSeries(model: any) { updateSeries(model: any) {
return this.httpClient.post(this.baseUrl + 'series/', model); return this.httpClient.post(this.baseUrl + 'series/update', model);
} }
markRead(seriesId: number) { markRead(seriesId: number) {
@ -90,22 +92,27 @@ export class SeriesService {
return this.httpClient.post<void>(this.baseUrl + 'reader/mark-unread', {seriesId}); 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(); let params = new HttpParams();
params = this._addPaginationIfExists(params, pageNum, itemsPerPage); params = this._addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.get<Series[]>(this.baseUrl + 'series/recently-added', {observe: 'response', params}).pipe( return this.httpClient.post<Series[]>(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map((response: any) => { map(response => {
return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults); return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
}) })
); );
} }
getInProgress(libraryId: number = 0) { getInProgress(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
return this.httpClient.get<Series[]>(this.baseUrl + 'series/in-progress?libraryId=' + libraryId).pipe(map(series => { const data = this.createSeriesFilter(filter);
series.forEach(s => s.coverImage = this.imageService.getSeriesCoverImage(s.id));
return series; 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; 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 { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AuthGuard } from './_guards/auth.guard'; import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.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 // 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], canActivate: [AuthGuard],
children: [ children: [
{path: 'recently-added', component: RecentlyAddedComponent}, {path: 'recently-added', component: RecentlyAddedComponent},
{path: 'in-progress', component: InProgressComponent},
{path: 'collections', component: AllCollectionsComponent}, {path: 'collections', component: AllCollectionsComponent},
{path: 'collections/:id', 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 { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component'; import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-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 { CarouselModule } from './carousel/carousel.module';
import { NgxSliderModule } from '@angular-slider/ngx-slider'; 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 { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { LibraryCardComponent } from './library-card/library-card.component'; import { LibraryCardComponent } from './library-card/library-card.component';
import { SeriesCardComponent } from './series-card/series-card.component'; import { SeriesCardComponent } from './series-card/series-card.component';
import { InProgressComponent } from './in-progress/in-progress.component';
let sentryProviders: any[] = []; let sentryProviders: any[] = [];
@ -103,7 +103,8 @@ if (environment.production) {
EditCollectionTagsComponent, EditCollectionTagsComponent,
RecentlyAddedComponent, RecentlyAddedComponent,
LibraryCardComponent, LibraryCardComponent,
SeriesCardComponent SeriesCardComponent,
InProgressComponent
], ],
imports: [ imports: [
HttpClientModule, HttpClientModule,
@ -120,7 +121,6 @@ if (environment.production) {
NgbAccordionModule, // User Preferences NgbAccordionModule, // User Preferences
NgxSliderModule, // User Preference NgxSliderModule, // User Preference
NgbPaginationModule, NgbPaginationModule,
LazyLoadImageModule,
SharedModule, SharedModule,
CarouselModule, CarouselModule,
TypeaheadModule, TypeaheadModule,
@ -136,7 +136,6 @@ if (environment.production) {
providers: [ providers: [
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},
{provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true},
//{ provide: LAZYLOAD_IMAGE_HOOKS, useClass: ScrollHooks } // Great, but causes flashing after modals close
Title, Title,
...sentryProviders, ...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" [items]="series"
[actions]="actions" [actions]="actions"
[pagination]="pagination" [pagination]="pagination"
[filters]="filters"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)" (pageChange)="onPageChange($event)"
> >
<ng-template #cardItem let-item let-position="idx"> <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 { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { UpdateFilterEvent } from '../shared/card-detail-layout/card-detail-layout.component';
import { Library } from '../_models/library'; import { Library } from '../_models/library';
import { Pagination } from '../_models/pagination'; import { Pagination } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
import { ActionService } from '../_services/action.service'; import { ActionService } from '../_services/action.service';
import { LibraryService } from '../_services/library.service'; import { LibraryService } from '../_services/library.service';
@ -23,9 +25,14 @@ export class LibraryDetailComponent implements OnInit {
loadingSeries = false; loadingSeries = false;
pagination!: Pagination; pagination!: Pagination;
actions: ActionItem<Library>[] = []; actions: ActionItem<Library>[] = [];
filters: Array<FilterItem> = mangaFormatFilters;
filter: SeriesFilter = {
mangaFormat: null
};
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, 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'); const routeId = this.route.snapshot.paramMap.get('id');
if (routeId === null) { if (routeId === null) {
this.router.navigateByUrl('/libraries'); this.router.navigateByUrl('/libraries');
@ -36,12 +43,14 @@ export class LibraryDetailComponent implements OnInit {
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => { this.libraryService.getLibraryNames().pipe(take(1)).subscribe(names => {
this.libraryName = names[this.libraryId]; this.libraryName = names[this.libraryId];
this.titleService.setTitle('Kavita - ' + this.libraryName); this.titleService.setTitle('Kavita - ' + this.libraryName);
}) });
this.loadPage();
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this)); this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1};
this.loadPage();
} }
ngOnInit(): void { ngOnInit(): void {
} }
handleAction(action: Action, library: Library) { handleAction(action: Action, library: Library) {
@ -61,17 +70,24 @@ export class LibraryDetailComponent implements OnInit {
} }
} }
loadPage() { updateFilter(data: UpdateFilterEvent) {
if (this.pagination == undefined || this.pagination == null) { this.filter.mangaFormat = data.filterItem.value;
this.pagination = {currentPage: 0, itemsPerPage: 30, totalItems: 0, totalPages: 1}; 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) { if (page != null) {
this.pagination.currentPage = parseInt(page, 10); this.pagination.currentPage = parseInt(page, 10);
} }
this.loadingSeries = true; 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.series = series.result;
this.pagination = series.pagination; this.pagination = series.pagination;
this.loadingSeries = false; this.loadingSeries = false;
@ -80,13 +96,19 @@ export class LibraryDetailComponent implements OnInit {
} }
onPageChange(pagination: Pagination) { 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) { seriesClicked(series: Series) {
this.router.navigate(['library', this.libraryId, 'series', series.id]); 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 { Title } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; 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 { EditCollectionTagsComponent } from '../_modals/edit-collection-tags/edit-collection-tags.component';
import { CollectionTag } from '../_models/collection-tag'; import { CollectionTag } from '../_models/collection-tag';
import { InProgressChapter } from '../_models/in-progress-chapter'; import { InProgressChapter } from '../_models/in-progress-chapter';
@ -20,7 +21,7 @@ import { SeriesService } from '../_services/series.service';
templateUrl: './library.component.html', templateUrl: './library.component.html',
styleUrls: ['./library.component.scss'] styleUrls: ['./library.component.scss']
}) })
export class LibraryComponent implements OnInit { export class LibraryComponent implements OnInit, OnDestroy {
user: User | undefined; user: User | undefined;
libraries: Library[] = []; libraries: Library[] = [];
@ -33,6 +34,8 @@ export class LibraryComponent implements OnInit {
collectionTags: CollectionTag[] = []; collectionTags: CollectionTag[] = [];
collectionTagActions: ActionItem<CollectionTag>[] = []; collectionTagActions: ActionItem<CollectionTag>[] = [];
private readonly onDestroy = new Subject<void>();
seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`; seriesTrackBy = (index: number, item: any) => `${item.name}_${item.pagesRead}`;
constructor(public accountService: AccountService, private libraryService: LibraryService, constructor(public accountService: AccountService, private libraryService: LibraryService,
@ -57,13 +60,18 @@ export class LibraryComponent implements OnInit {
this.reloadSeries(); this.reloadSeries();
} }
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
reloadSeries() { 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.recentlyAdded = updatedSeries.result;
}); });
this.seriesService.getInProgress().subscribe((updatedSeries) => { this.seriesService.getInProgress().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => {
this.inProgress = updatedSeries; this.inProgress = updatedSeries.result;
}); });
this.reloadTags(); this.reloadTags();
@ -78,15 +86,15 @@ export class LibraryComponent implements OnInit {
return; return;
} }
this.seriesService.getInProgress().subscribe((updatedSeries) => { this.seriesService.getInProgress().pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => {
this.inProgress = updatedSeries; this.inProgress = updatedSeries.result;
}); });
this.reloadTags(); this.reloadTags();
} }
reloadTags() { reloadTags() {
this.collectionService.allTags().subscribe(tags => { this.collectionService.allTags().pipe(takeUntil(this.onDestroy)).subscribe(tags => {
this.collectionTags = tags; this.collectionTags = tags;
}); });
} }
@ -96,7 +104,9 @@ export class LibraryComponent implements OnInit {
this.router.navigate(['collections']); this.router.navigate(['collections']);
} else if (sectionTitle.toLowerCase() === 'recently added') { } else if (sectionTitle.toLowerCase() === 'recently added') {
this.router.navigate(['recently-added']); this.router.navigate(['recently-added']);
} } else if (sectionTitle.toLowerCase() === 'in progress') {
this.router.navigate(['in-progress']);
}
} }
loadCollection(item: CollectionTag) { loadCollection(item: CollectionTag) {

View File

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

View File

@ -1,7 +1,11 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; 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 { Pagination } from '../_models/pagination';
import { Series } from '../_models/series'; import { Series } from '../_models/series';
import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series-filter';
import { SeriesService } from '../_services/series.service'; import { SeriesService } from '../_services/series.service';
/** /**
@ -19,36 +23,57 @@ export class RecentlyAddedComponent implements OnInit {
pagination!: Pagination; pagination!: Pagination;
libraryId!: number; libraryId!: number;
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService) { filters: Array<FilterItem> = mangaFormatFilters;
this.router.routeReuseStrategy.shouldReuseRoute = () => false; 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(); this.loadPage();
} }
ngOnInit() {}
seriesClicked(series: Series) { seriesClicked(series: Series) {
this.router.navigate(['library', this.libraryId, 'series', series.id]); this.router.navigate(['library', this.libraryId, 'series', series.id]);
} }
onPageChange(pagination: Pagination) { 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() { loadPage() {
const page = this.route.snapshot.queryParamMap.get('page'); const page = this.getPage();
if (page != null) { if (page != null) {
if (this.pagination === undefined || this.pagination === null) { this.pagination.currentPage = parseInt(page, 10);
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);
});
} }
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="container-fluid" *ngIf="series !== undefined" style="padding-top: 10px">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-2 col-xs-4 col-sm-6"> <div class="col-md-2 col-xs-4 col-sm-6">
<img class="poster" [lazyLoad]="imageService.getSeriesCoverImage(series.id)" [defaultImage]="imageService.placeholderImage"> <img class="poster lazyload" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageService.getSeriesCoverImage(series.id)"
(error)="imageSerivce.updateErroredImage($event)" aria-hidden="true">
</div> </div>
<div class="col-md-10 col-xs-8 col-sm-6"> <div class="col-md-10 col-xs-8 col-sm-6">
<div class="row no-gutters"> <div class="row no-gutters">

View File

@ -86,7 +86,8 @@ export class SeriesDetailComponent implements OnInit {
private accountService: AccountService, public imageService: ImageService, private accountService: AccountService, public imageService: ImageService,
private actionFactoryService: ActionFactoryService, private libraryService: LibraryService, private actionFactoryService: ActionFactoryService, private libraryService: LibraryService,
private confirmService: ConfirmService, private titleService: Title, private confirmService: ConfirmService, private titleService: Title,
private downloadService: DownloadService, private actionService: ActionService) { private downloadService: DownloadService, private actionService: ActionService,
public imageSerivce: ImageService) {
ratingConfig.max = 5; ratingConfig.max = 5;
this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {

View File

@ -1,8 +1,31 @@
<div class="container-fluid" style="padding-top: 10px"> <div class="container-fluid" style="padding-top: 10px">
<h2><span *ngIf="actions.length > 0" class=""> <div class="row no-gutters">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables> <div class="col mr-auto">
</span>&nbsp;{{header}}</h2> <h2 style="display: inline-block">
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container> <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"> <div class="row no-gutters">

View File

@ -1,9 +1,33 @@
import { Component, ContentChild, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core'; 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 { Pagination } from 'src/app/_models/pagination';
import { FilterItem } from 'src/app/_models/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service'; import { ActionItem } from 'src/app/_services/action-factory.service';
const FILTER_PAG_REGEX = /[^0-9]/g; 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({ @Component({
selector: 'app-card-detail-layout', selector: 'app-card-detail-layout',
templateUrl: './card-detail-layout.component.html', templateUrl: './card-detail-layout.component.html',
@ -15,12 +39,29 @@ export class CardDetailLayoutComponent implements OnInit {
@Input() isLoading: boolean = false; @Input() isLoading: boolean = false;
@Input() items: any[] = []; @Input() items: any[] = [];
@Input() pagination!: Pagination; @Input() pagination!: Pagination;
/**
* Any actions to exist on the header for the parent collection (library, collection)
*/
@Input() actions: ActionItem<any>[] = []; @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; @Input() trackByIdentity!: (index: number, item: any) => string;
@Output() itemClicked: EventEmitter<any> = new EventEmitter(); @Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter(); @Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() applyFilter: EventEmitter<UpdateFilterEvent> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>; @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() { } 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="card">
<div class="overlay" (click)="handleClick()"> <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" [src]="imageSerivce.placeholderImage" [attr.data-src]="imageUrl"
<img *ngIf="total === 0 && !supressArchiveWarning" class="card-img-top" [lazyLoad]="imageSerivce.errorImage" alt="title"> (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)"> <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> <p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
</div> </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 { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service'; import { LibraryService } from 'src/app/_services/library.service';
import { UtilityService } from '../_services/utility.service'; import { UtilityService } from '../_services/utility.service';
// import 'lazysizes';
// import 'lazysizes/plugins/attrchange/ls.attrchange';
@Component({ @Component({
selector: 'app-card-item', selector: 'app-card-item',
@ -38,8 +40,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
private readonly onDestroy = new Subject<void>(); 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 { ngOnInit(): void {
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) { if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
@ -59,6 +60,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
ngOnDestroy() { ngOnDestroy() {
this.onDestroy.next(); this.onDestroy.next();
this.onDestroy.complete();
} }
handleClick() { handleClick() {

View File

@ -10,7 +10,7 @@
// Custom animation for ng-lazyload-image // Custom animation for ng-lazyload-image
img.ng-lazyloaded { img.ng-lazyloaded {
animation: fadein .5s; //animation: fadein .5s; // I think it might look better without animation
} }
@keyframes fadein { @keyframes fadein {
from { opacity: 0; } from { opacity: 0; }