Want to Read List (#1392)

* Implemented a Want To Read list of series for all users, as a way to keep track of what you want to read.

When canceling a bulk action, like Add to Reading list, the selected cards wont de-select.

* Hooked up Remove from Want to Read

* When making bulk selection, allow the user to click on anywhere on the card

* Added no series messaging

* Code cleanup
This commit is contained in:
Joseph Milazzo 2022-07-28 17:18:35 -05:00 committed by GitHub
parent 495c986000
commit f130440bd0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 2209 additions and 48 deletions

View File

@ -178,7 +178,7 @@ namespace API.Controllers
if (!validPassword)
{
return Unauthorized("Your credentials are not correct"); // TODO: Refactor backend to send back the string for i8ln
return Unauthorized("Your credentials are not correct");
}
var result = await _signInManager

View File

@ -4,8 +4,10 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;

View File

@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.WantToRead;
using API.Extensions;
using API.Helpers;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
/// <summary>
/// Responsible for all things Want To Read
/// </summary>
[Route("api/want-to-read")]
public class WantToReadController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
public WantToReadController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered
/// </summary>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto)
{
userParams ??= new UserParams();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
return Ok(pagedList);
}
/// <summary>
/// Given a list of Series Ids, add them to the current logged in user's Want To Read list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("add-series")]
public async Task<ActionResult> AddSeries(UpdateWantToReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
AppUserIncludes.WantToRead);
var existingIds = user.WantToRead.Select(s => s.Id).ToList();
existingIds.AddRange(dto.SeriesIds);
var idsToAdd = existingIds.Distinct().ToList();
var seriesToAdd = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(idsToAdd);
foreach (var series in seriesToAdd)
{
user.WantToRead.Add(series);
}
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest("There was an issue updating Read List");
}
/// <summary>
/// Given a list of Series Ids, remove them from the current logged in user's Want To Read list
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("remove-series")]
public async Task<ActionResult> RemoveSeries(UpdateWantToReadDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(),
AppUserIncludes.WantToRead);
user.WantToRead = user.WantToRead.Where(s => @dto.SeriesIds.Contains(s.Id)).ToList();
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync()) return Ok();
return BadRequest("There was an issue updating Read List");
}
}

View File

@ -6,6 +6,9 @@
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
/// <summary>
/// The cover image string. This is used on Frontend to show or hide the Cover Image
/// </summary>
public string CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
}

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace API.DTOs.WantToRead;
/// <summary>
/// A list of Series to pass when working with Want To Read APIs
/// </summary>
public class UpdateWantToReadDto
{
/// <summary>
/// List of Series Ids that will be Added/Removed
/// </summary>
public IList<int> SeriesIds { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class WantToReadList : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AppUserId",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Series_AppUserId",
table: "Series",
column: "AppUserId");
migrationBuilder.AddForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series",
column: "AppUserId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_Series_AppUserId",
table: "Series");
migrationBuilder.DropColumn(
name: "AppUserId",
table: "Series");
}
}
}

View File

@ -758,6 +758,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
@ -820,6 +823,8 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("LibraryId");
b.ToTable("Series");
@ -1339,6 +1344,10 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany("WantToRead")
.HasForeignKey("AppUserId");
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
@ -1533,6 +1542,8 @@ namespace API.Data.Migrations
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
b.Navigation("WantToRead");
});
modelBuilder.Entity("API.Entities.Chapter", b =>

View File

@ -119,6 +119,7 @@ public interface ISeriesRepository
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
Task<SeriesDto> GetSeriesForMangaFile(int mangaFileId, int userId);
Task<SeriesDto> GetSeriesForChapter(int chapterId, int userId);
Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter);
}
public class SeriesRepository : ISeriesRepository
@ -715,7 +716,6 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
@ -778,6 +778,68 @@ public class SeriesRepository : ISeriesRepository
return query;
}
private async Task<IQueryable<Series>> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter, IQueryable<Series> sQuery)
{
var userLibraries = await GetUserLibraries(libraryId, userId);
var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries,
out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter,
out var hasCollectionTagFilter, out var hasRatingFilter, out var hasProgressFilter,
out var seriesIds, out var hasAgeRating, out var hasTagsFilter, out var hasLanguageFilter, out var hasPublicationFilter, out var hasSeriesNameFilter);
var query = sQuery
.Where(s => userLibraries.Contains(s.LibraryId)
&& formats.Contains(s.Format)
&& (!hasGenresFilter || s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id)))
&& (!hasPeopleFilter || s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id)))
&& (!hasCollectionTagFilter ||
s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id)))
&& (!hasRatingFilter || s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId))
&& (!hasProgressFilter || seriesIds.Contains(s.Id))
&& (!hasAgeRating || filter.AgeRating.Contains(s.Metadata.AgeRating))
&& (!hasTagsFilter || s.Metadata.Tags.Any(t => filter.Tags.Contains(t.Id)))
&& (!hasLanguageFilter || filter.Languages.Contains(s.Metadata.Language))
&& (!hasPublicationFilter || filter.PublicationStatus.Contains(s.Metadata.PublicationStatus)))
.Where(s => !hasSeriesNameFilter ||
EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%")
|| EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%"))
.AsNoTracking();
// If no sort options, default to using SortName
filter.SortOptions ??= new SortOptions()
{
IsAscending = true,
SortField = SortField.SortName
};
if (filter.SortOptions.IsAscending)
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderBy(s => s.SortName),
SortField.CreatedDate => query.OrderBy(s => s.Created),
SortField.LastModifiedDate => query.OrderBy(s => s.LastModified),
SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead),
_ => query
};
}
else
{
query = filter.SortOptions.SortField switch
{
SortField.SortName => query.OrderByDescending(s => s.SortName),
SortField.CreatedDate => query.OrderByDescending(s => s.Created),
SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified),
SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded),
SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead),
_ => query
};
}
return query;
}
public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)
{
var metadataDto = await _context.SeriesMetadata
@ -1074,6 +1136,21 @@ public class SeriesRepository : ISeriesRepository
.SingleOrDefaultAsync();
}
public async Task<PagedList<SeriesDto>> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter)
{
var libraryIds = GetLibraryIdsForUser(userId);
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.AsSplitQuery()
.AsNoTracking();
var filteredQuery = await CreateFilteredSearchQueryable(userId, 0, filter, query);
return await PagedList<SeriesDto>.CreateAsync(filteredQuery.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider), userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams)
{
@ -1238,7 +1315,6 @@ public class SeriesRepository : ISeriesRepository
VolumeNumber = c.Volume.Number,
ChapterTitle = c.Title
})
//.Take(maxRecords)
.AsSplitQuery()
.Where(c => c.Created >= withinLastWeek && libraryIds.Contains(c.LibraryId))
.AsEnumerable();

View File

@ -22,7 +22,8 @@ public enum AppUserIncludes
Bookmarks = 4,
ReadingLists = 8,
Ratings = 16,
UserPreferences = 32
UserPreferences = 32,
WantToRead = 64
}
public interface IUserRepository
@ -176,6 +177,11 @@ public class UserRepository : IUserRepository
query = query.Include(u => u.UserPreferences);
}
if (includeFlags.HasFlag(AppUserIncludes.WantToRead))
{
query = query.Include(u => u.WantToRead);
}
return query;

View File

@ -22,6 +22,10 @@ namespace API.Entities
/// </summary>
public ICollection<ReadingList> ReadingLists { get; set; }
/// <summary>
/// A list of Series the user want's to read
/// </summary>
public ICollection<Series> WantToRead { get; set; }
/// <summary>
/// An API Key to interact with external services, like OPDS
/// </summary>
public string ApiKey { get; set; }

View File

@ -85,8 +85,6 @@ namespace Kavita.Common.EnvironmentInfo
public OsInfo()
{
OsVersionModel osInfo = null;
Name = Os.ToString();
FullName = Name;

View File

@ -69,6 +69,14 @@ export enum Action {
* Open the reader for entity
*/
Read = 14,
/**
* Add to user's Want to Read List
*/
AddToWantToReadList = 15,
/**
* Remove from user's Want to Read List
*/
RemoveFromWantToReadList = 16,
}
export interface ActionItem<T> {
@ -276,6 +284,12 @@ export class ActionFactoryService {
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.AddToWantToReadList,
title: 'Add to Want To Read',
callback: this.dummyCallback,
requiresAdmin: false
}
];

View File

@ -13,6 +13,7 @@ import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { LibraryService } from './library.service';
import { MemberService } from './member.service';
import { ReaderService } from './reader.service';
import { SeriesService } from './series.service';
@ -33,13 +34,12 @@ export type BooleanActionCallback = (result: boolean) => void;
export class ActionService implements OnDestroy {
private readonly onDestroy = new Subject<void>();
private bookmarkModalRef: NgbModalRef | null = null;
private readingListModalRef: NgbModalRef | null = null;
private collectionModalRef: NgbModalRef | null = null;
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService) { }
private confirmService: ConfirmService, private memberService: MemberService) { }
ngOnDestroy() {
this.onDestroy.next();
@ -342,7 +342,7 @@ export class ActionService implements OnDestroy {
});
}
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesId = seriesId;
@ -355,18 +355,36 @@ export class ActionService implements OnDestroy {
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback();
callback(true);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback();
callback(false);
}
});
}
addMultipleSeriesToReadingList(series: Array<Series>, callback?: VoidActionCallback) {
addMultipleSeriesToWantToReadList(seriesIds: Array<number>, callback?: VoidActionCallback) {
this.memberService.addSeriesToWantToRead(seriesIds).subscribe(() => {
this.toastr.success('Series added to Want to Read list');
if (callback) {
callback();
}
});
}
removeMultipleSeriesFromWantToReadList(seriesIds: Array<number>, callback?: VoidActionCallback) {
this.memberService.removeSeriesToWantToRead(seriesIds).subscribe(() => {
this.toastr.success('Series removed from Want to Read list');
if (callback) {
callback();
}
});
}
addMultipleSeriesToReadingList(series: Array<Series>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id);
@ -377,13 +395,13 @@ export class ActionService implements OnDestroy {
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback();
callback(true);
}
});
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.readingListModalRef = null;
if (callback) {
callback();
callback(false);
}
});
}
@ -394,7 +412,7 @@ export class ActionService implements OnDestroy {
* @param callback
* @returns
*/
addMultipleSeriesToCollectionTag(series: Array<Series>, callback?: VoidActionCallback) {
addMultipleSeriesToCollectionTag(series: Array<Series>, callback?: BooleanActionCallback) {
if (this.collectionModalRef != null) { return; }
this.collectionModalRef = this.modalService.open(BulkAddToCollectionComponent, { scrollable: true, size: 'md', windowClass: 'collection' });
this.collectionModalRef.componentInstance.seriesIds = series.map(v => v.id);
@ -403,13 +421,13 @@ export class ActionService implements OnDestroy {
this.collectionModalRef.closed.pipe(take(1)).subscribe(() => {
this.collectionModalRef = null;
if (callback) {
callback();
callback(true);
}
});
this.collectionModalRef.dismissed.pipe(take(1)).subscribe(() => {
this.collectionModalRef = null;
if (callback) {
callback();
callback(false);
}
});
}

View File

@ -36,8 +36,16 @@ export class MemberService {
return this.httpClient.get<boolean>(this.baseUrl + 'users/has-reading-progress?libraryId=' + librayId);
}
getPendingInvites() {
return this.httpClient.get<Array<Member>>(this.baseUrl + 'users/pending');
}
addSeriesToWantToRead(seriesIds: Array<number>) {
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/add-series', {seriesIds});
}
removeSeriesToWantToRead(seriesIds: Array<number>) {
return this.httpClient.post<Array<Member>>(this.baseUrl + 'want-to-read/remove-series', {seriesIds});
}
}

View File

@ -1,6 +1,6 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { UtilityService } from '../shared/_services/utility.service';
@ -124,6 +124,18 @@ export class SeriesService {
return this.httpClient.post<SeriesGroup[]>(this.baseUrl + 'series/recently-updated-series', {});
}
getWantToRead(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter): Observable<PaginatedResult<Series[]>> {
const data = this.createSeriesFilter(filter);
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.post<Series[]>(this.baseUrl + 'want-to-read/', data, {observe: 'response', params}).pipe(
map(response => {
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
}));
}
getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) {
const data = this.createSeriesFilter(filter);

View File

@ -41,13 +41,23 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
switch (action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
});
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
});
break;
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
});
break;
case Action.AddToCollection:
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
this.bulkSelectionService.deselectAll();
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
});
break;
case Action.MarkAsRead:

View File

@ -45,6 +45,10 @@ const routes: Routes = [
path: 'libraries',
loadChildren: () => import('../app/dashboard/dashboard.module').then(m => m.DashboardModule)
},
{
path: 'want-to-read',
loadChildren: () => import('../app/want-to-read/want-to-read.module').then(m => m.WantToReadModule)
},
{
path: 'library',
runGuardsAndResolvers: 'always',

View File

@ -2,7 +2,7 @@
<h2 title>
Bookmarks
</h2>
<h6 subtitle>{{series?.length}} Series</h6>
<h6 subtitle>{{series.length}} Series</h6>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout

View File

@ -141,7 +141,7 @@ export class BulkSelectionService {
getActions(callback: (action: Action, data: any) => void) {
// checks if series is present. If so, returns only series actions
// else returns volume/chapter items
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection, Action.Delete];
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection, Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList];
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
return this.actionFactory.getSeriesActions(callback).filter(item => allowedActions.includes(item.action));
}

View File

@ -16,6 +16,9 @@
<div class="viewport-container">
<div class="content-container">
<div class="card-container mt-2 mb-2">
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
<virtual-scroller #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
@ -23,10 +26,6 @@
</div>
</div>
</virtual-scroller>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</div>
</div>
@ -42,7 +41,9 @@
</virtual-scroller>
<div class="mx-auto" *ngIf="items.length === 0 && !isLoading" style="width: 200px;">
<p><ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container></p>
<p>
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</div>
</ng-template>

View File

@ -117,6 +117,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges,
// }
// this.hasResumedJumpKey = true;
// });
console.log(this.noDataTemplate);
}
ngOnChanges(): void {

View File

@ -19,7 +19,7 @@
<div class="not-read-badge" *ngIf="read === 0 && total > 0"></div>
<div class="bulk-mode {{bulkSelectionService.hasSelections() ? 'always-show' : ''}}" (click)="handleSelection($event)" *ngIf="allowSelection">
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity?.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
<input type="checkbox" class="form-check-input" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
</div>
<div class="count" *ngIf="count > 1">
@ -35,7 +35,7 @@
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
<div>
<span class="card-title" placement="top" id="{{title}}_{{entity?.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" [ngbTooltip]="tooltipTitle" (click)="handleClick($event)" tabindex="0">
<span *ngIf="isPromoted()">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
<span class="visually-hidden">(promoted)</span>

View File

@ -258,6 +258,10 @@ export class CardItemComponent implements OnInit, OnDestroy {
handleClick(event?: any) {
if (this.bulkSelectionService.hasSelections()) {
this.handleSelection();
return;
}
this.clicked.emit(this.title);
}

View File

@ -97,6 +97,9 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series);
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList([series.id]);
break;
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series]);
break;

View File

@ -37,10 +37,4 @@
></app-series-card>
</ng-template>
</app-card-detail-layout>
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">
<div class="spinner-border text-secondary loading" role="status">
<span class="invisible">Loading...</span>
</div>
</div>
</div>

View File

@ -63,14 +63,26 @@ export class CollectionDetailComponent implements OnInit, OnDestroy, AfterConten
switch (action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.AddToCollection:
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
this.bulkSelectionService.deselectAll();
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;

View File

@ -3,7 +3,7 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
{{libraryName}}
</h2>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{pagination?.totalItems}} Series</h6>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{pagination.totalItems}} Series</h6>
<div main>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills" style="flex-wrap: nowrap;">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">

View File

@ -57,14 +57,26 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
switch (action) {
case Action.AddToReadingList:
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
this.actionService.addMultipleSeriesToReadingList(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
case Action.AddToCollection:
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
this.bulkSelectionService.deselectAll();
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, (success) => {
if (success) this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;

View File

@ -180,9 +180,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
switch (action) {
case Action.AddToReadingList:
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, () => {
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => {
this.actionInProgress = false;
this.bulkSelectionService.deselectAll();
if (success) this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
break;
@ -379,6 +379,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.changeDetectionRef.markForCheck();
});
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList([series.id], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
break;
case (Action.Download):
if (this.downloadInProgress) return;
this.downloadSeries();

View File

@ -1,5 +1,5 @@
<ng-container>
<div class="side-nav" [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async), 'hidden' :!(navService?.sideNavVisibility$ | async)}" *ngIf="accountService.currentUser$ | async as user">
<div class="side-nav" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'hidden' :!(navService.sideNavVisibility$ | async)}" *ngIf="accountService.currentUser$ | async as user">
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">
<ng-container actions>
Todo: This will be customize dashboard/side nav controls
@ -8,6 +8,7 @@
</app-side-nav-item> -->
<app-side-nav-item icon="fa-home" title="Home" link="/libraries/"></app-side-nav-item>
<app-side-nav-item icon="fa-star" title="Want To Read" link="/want-to-read/"></app-side-nav-item>
<app-side-nav-item icon="fa-list" title="Collections" link="/collections/"></app-side-nav-item>
<app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists/"></app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item>
@ -26,5 +27,5 @@
</ng-container>
</app-side-nav-item>
</div>
<div class="side-nav-overlay" (click)="toggleNavBar()" [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async)}"></div>
<div class="side-nav-overlay" (click)="toggleNavBar()" [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async)}"></div>
</ng-container>

View File

@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../_guards/auth.guard';
import { WantToReadComponent } from './want-to-read/want-to-read.component';
const routes: Routes = [
{path: '**', component: WantToReadComponent, pathMatch: 'full'},
{
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
children: [
{path: '', component: WantToReadComponent, pathMatch: 'full'},
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class WantToReadRoutingModule { }

View File

@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { WantToReadComponent } from './want-to-read/want-to-read.component';
import { CardsModule } from '../cards/cards.module';
import { SidenavModule } from '../sidenav/sidenav.module';
import { WantToReadRoutingModule } from './want-to-read-routing.module';
@NgModule({
declarations: [
WantToReadComponent
],
imports: [
CommonModule,
CardsModule,
SidenavModule,
WantToReadRoutingModule
]
})
export class WantToReadModule { }

View File

@ -0,0 +1,33 @@
<div #companionBar>
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<ng-container title>
<h2>
Want To Read
</h2>
</ng-container>
</app-side-nav-companion-bar>
</div>
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" #scrollingBlock>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
[isLoading]="isLoading"
[items]="series"
[pagination]="seriesPagination"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"
[parentScroll]="scrollingBlock"
[jumpBarKeys]="jumpbarKeys"
(applyFilter)="updateFilter($event)">
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"
(selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"
></app-series-card>
</ng-template>
<ng-template #noData>
No Series match your filter or exist in your list.
</ng-template>
</app-card-detail-layout>
</div>

View File

@ -0,0 +1,146 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router, ActivatedRoute } from '@angular/router';
import { Subject, take, pipe, debounceTime, takeUntil } from 'rxjs';
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
import { UtilityService, KEY_CODES } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Pagination } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { SeriesFilter, FilterEvent } from 'src/app/_models/series-filter';
import { Action } from 'src/app/_services/action-factory.service';
import { ActionService } from 'src/app/_services/action.service';
import { ImageService } from 'src/app/_services/image.service';
import { MessageHubService, EVENTS } from 'src/app/_services/message-hub.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-want-to-read',
templateUrl: './want-to-read.component.html',
styleUrls: ['./want-to-read.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WantToReadComponent implements OnInit, OnDestroy {
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
isLoading: boolean = true;
series: Array<Series> = [];
seriesPagination!: Pagination;
filter: SeriesFilter | undefined = undefined;
filterSettings: FilterSettings = new FilterSettings();
filterActiveCheck!: SeriesFilter;
filterActive: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
filterOpen: EventEmitter<boolean> = new EventEmitter();
private onDestory: Subject<void> = new Subject<void>();
bulkActionCallback = (action: Action, data: any) => {
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
switch (action) {
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList(selectedSeries.map(s => s.id), () => {
this.bulkSelectionService.deselectAll();
this.cdRef.markForCheck();
});
break;
}
}
collectionTag: any;
tagImage: any;
get ScrollingBlockHeight() {
if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)';
const navbar = this.document.querySelector('.navbar') as HTMLElement;
if (navbar === null) return 'calc(var(--vh)*100)';
const companionHeight = this.companionBar!.nativeElement.offsetHeight;
const navbarHeight = navbar.offsetHeight;
const totalHeight = companionHeight + navbarHeight + 21; //21px to account for padding
return 'calc(var(--vh)*100 - ' + totalHeight + 'px)';
}
constructor(public imageService: ImageService, private router: Router, private route: ActivatedRoute,
private seriesService: SeriesService, private titleService: Title,
public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService,
private filterUtilityService: FilterUtilitiesService, private utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private readonly cdRef: ChangeDetectorRef, private scrollService: ScrollService) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.titleService.setTitle('Want To Read');
this.seriesPagination = this.filterUtilityService.pagination(this.route.snapshot);
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
this.filterActiveCheck = this.seriesService.createSeriesFilter();
this.cdRef.markForCheck();
}
ngOnInit(): void {
this.messageHub.messages$.pipe(takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => {
if (event.event === EVENTS.SeriesRemoved) {
this.loadPage();
}
});
}
ngAfterContentChecked(): void {
this.scrollService.setScrollContainer(this.scrollingBlock);
}
ngOnDestroy() {
this.onDestory.next();
this.onDestory.complete();
}
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = true;
}
}
@HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) {
if (event.key === KEY_CODES.SHIFT) {
this.bulkSelectionService.isShiftDown = false;
}
}
loadPage() {
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
this.isLoading = true;
this.cdRef.markForCheck();
this.seriesService.getWantToRead(undefined, undefined, this.filter).pipe(take(1)).subscribe(paginatedList => {
this.series = paginatedList.result;
this.seriesPagination = paginatedList.pagination;
this.jumpbarKeys = this.utilityService.getJumpKeys(this.series, (series: Series) => series.name);
this.isLoading = false;
window.scrollTo(0, 0);
this.cdRef.markForCheck();
});
}
updateFilter(data: FilterEvent) {
this.filter = data.filter;
if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.seriesPagination, this.filter);
this.loadPage();
}
}