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:
parent
495c986000
commit
f130440bd0
36 changed files with 2209 additions and 48 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
90
API/Controllers/WantToReadController.cs
Normal file
90
API/Controllers/WantToReadController.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
14
API/DTOs/WantToRead/UpdateWantToReadDto.cs
Normal file
14
API/DTOs/WantToRead/UpdateWantToReadDto.cs
Normal 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; }
|
||||
}
|
||||
1590
API/Data/Migrations/20220728193758_WantToReadList.Designer.cs
generated
Normal file
1590
API/Data/Migrations/20220728193758_WantToReadList.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
45
API/Data/Migrations/20220728193758_WantToReadList.cs
Normal file
45
API/Data/Migrations/20220728193758_WantToReadList.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue