mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Bulk Operations (#596)
* Implemented the ability to perform multi-selections on cards. Basic selection code is done, CSS needed and exposing actions. * Implemented a bulk selection bar. Added logic to the card on when to force show checkboxes. * Fixed some bad parsing groups and cases for Comic Chapters. * Hooked up some bulk actions on series detail page. Not hooked up to backend yet. * Fixes #593. URI Enocde library names as sometimes they can have & in them. * Implemented the ability to mark volume/chapters as read/unread. * Hooked up mark as unread with specials as well. * Add to reading list hooked up for Series Detail * Implemented ability to add multiple series to a reading list. * Implemented bulk selection for series cards * Added comments to the new code in ReaderService.cs * Implemented proper styling on bulk operation bar and integrated for collections. * Fixed an issue with shift clicking * Cleaned up css of bulk operations bar * Code cleanup
This commit is contained in:
parent
52c4285168
commit
f5229fd0e6
@ -58,17 +58,17 @@ namespace API.Tests.Parser
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("01 Spider-Man & Wolverine 01.cbr", "0")]
|
||||
[InlineData("01 Spider-Man & Wolverine 01.cbr", "1")]
|
||||
[InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "0")]
|
||||
[InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "0")]
|
||||
[InlineData("Batman & Catwoman - Trail of the Gun 01", "0")]
|
||||
[InlineData("Batman & Catwoman - Trail of the Gun 01", "1")]
|
||||
[InlineData("Batman & Daredevil - King of New York", "0")]
|
||||
[InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")]
|
||||
[InlineData("Batman & Robin the Teen Wonder #0", "0")]
|
||||
[InlineData("Batman & Wildcat (1 of 3)", "1")]
|
||||
[InlineData("Batman & Wildcat (2 of 3)", "2")]
|
||||
[InlineData("Batman And Superman World's Finest #01", "0")]
|
||||
[InlineData("Babe 01", "0")]
|
||||
[InlineData("Batman And Superman World's Finest #01", "1")]
|
||||
[InlineData("Babe 01", "1")]
|
||||
[InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", "1")]
|
||||
[InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")]
|
||||
[InlineData("Superman v1 024 (09-10 1943)", "24")]
|
||||
@ -78,6 +78,8 @@ namespace API.Tests.Parser
|
||||
[InlineData("Batman Wayne Family Adventures - Ep. 014 - Moving In", "14")]
|
||||
[InlineData("Saga 001 (2012) (Digital) (Empire-Zone)", "1")]
|
||||
[InlineData("Batman Beyond 04 (of 6) (1999)", "4")]
|
||||
[InlineData("Invincible 052 (c2c) (2008) (Minutemen-TheCouple)", "52")]
|
||||
[InlineData("Y - The Last Man #001", "1")]
|
||||
public void ParseComicChapterTest(string filename, string expected)
|
||||
{
|
||||
Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename));
|
||||
|
BIN
API.Tests/Services/Test Data/ImageService/cover.expected.jpg
Normal file
BIN
API.Tests/Services/Test Data/ImageService/cover.expected.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 462 KiB |
@ -101,27 +101,7 @@ namespace API.Controllers
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = chapter.Pages,
|
||||
VolumeId = volume.Id,
|
||||
SeriesId = markReadDto.SeriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = chapter.Pages;
|
||||
userProgress.SeriesId = markReadDto.SeriesId;
|
||||
userProgress.VolumeId = volume.Id;
|
||||
}
|
||||
}
|
||||
_readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
@ -135,30 +115,6 @@ namespace API.Controllers
|
||||
return BadRequest("There was an issue saving progress");
|
||||
}
|
||||
|
||||
private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
|
||||
{
|
||||
AppUserProgress userProgress = null;
|
||||
try
|
||||
{
|
||||
userProgress =
|
||||
user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
|
||||
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
|
||||
if (progresses.Count > 1)
|
||||
{
|
||||
user.Progresses = new List<AppUserProgress>()
|
||||
{
|
||||
user.Progresses.First()
|
||||
};
|
||||
userProgress = user.Progresses.First();
|
||||
}
|
||||
}
|
||||
|
||||
return userProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a Series as Unread (progress)
|
||||
@ -175,7 +131,7 @@ namespace API.Controllers
|
||||
{
|
||||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
var userProgress = ReaderService.GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null) continue;
|
||||
userProgress.PagesRead = 0;
|
||||
@ -206,28 +162,7 @@ namespace API.Controllers
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
|
||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
var userProgress = user.Progresses.FirstOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = 0,
|
||||
VolumeId = markVolumeReadDto.VolumeId,
|
||||
SeriesId = markVolumeReadDto.SeriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = 0;
|
||||
userProgress.SeriesId = markVolumeReadDto.SeriesId;
|
||||
userProgress.VolumeId = markVolumeReadDto.VolumeId;
|
||||
}
|
||||
}
|
||||
_readerService.MarkChaptersAsUnread(user, markVolumeReadDto.SeriesId, chapters);
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
@ -250,27 +185,119 @@ namespace API.Controllers
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
|
||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersAsync(markVolumeReadDto.VolumeId);
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
var userProgress = user.Progresses.FirstOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
|
||||
_readerService.MarkChaptersAsRead(user, markVolumeReadDto.SeriesId, chapters);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = chapter.Pages,
|
||||
VolumeId = markVolumeReadDto.VolumeId,
|
||||
SeriesId = markVolumeReadDto.SeriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = chapter.Pages;
|
||||
userProgress.SeriesId = markVolumeReadDto.SeriesId;
|
||||
userProgress.VolumeId = markVolumeReadDto.VolumeId;
|
||||
}
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest("Could not save progress");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Marks all chapters within a list of volumes as Read. All volumes must belong to the same Series.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("mark-multiple-read")]
|
||||
public async Task<ActionResult> MarkMultipleAsRead(MarkVolumesReadDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
|
||||
var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds);
|
||||
foreach (var chapterId in dto.ChapterIds)
|
||||
{
|
||||
chapterIds.Add(chapterId);
|
||||
}
|
||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds);
|
||||
_readerService.MarkChaptersAsRead(user, dto.SeriesId, chapters);
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest("Could not save progress");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all chapters within a list of volumes as Unread. All volumes must belong to the same Series.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("mark-multiple-unread")]
|
||||
public async Task<ActionResult> MarkMultipleAsUnread(MarkVolumesReadDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
|
||||
var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds);
|
||||
foreach (var chapterId in dto.ChapterIds)
|
||||
{
|
||||
chapterIds.Add(chapterId);
|
||||
}
|
||||
var chapters = await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds);
|
||||
_readerService.MarkChaptersAsUnread(user, dto.SeriesId, chapters);
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest("Could not save progress");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all chapters within a list of series as Read.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("mark-multiple-series-read")]
|
||||
public async Task<ActionResult> MarkMultipleSeriesAsRead(MarkMultipleSeriesAsReadDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true);
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest("Could not save progress");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all chapters within a list of series as Unread.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("mark-multiple-series-unread")]
|
||||
public async Task<ActionResult> MarkMultipleSeriesAsUnread(MarkMultipleSeriesAsReadDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
|
||||
user.Progresses ??= new List<AppUserProgress>();
|
||||
|
||||
var volumes = await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true);
|
||||
foreach (var volume in volumes)
|
||||
{
|
||||
_readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
@ -212,7 +212,7 @@ namespace API.Controllers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update a
|
||||
/// Update the properites (title, summary) of a reading list
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
@ -242,6 +242,11 @@ namespace API.Controllers
|
||||
return BadRequest("Could not update reading list");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all chapters from a Series to a reading list
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-by-series")]
|
||||
public async Task<ActionResult> UpdateListBySeries(UpdateReadingListBySeriesDto dto)
|
||||
{
|
||||
@ -273,6 +278,86 @@ namespace API.Controllers
|
||||
return Ok("Nothing to do");
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Adds all chapters from a list of volumes and chapters to a reading list
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-by-multiple")]
|
||||
public async Task<ActionResult> UpdateListByMultiple(UpdateReadingListByMultipleDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
|
||||
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
|
||||
if (readingList == null) return BadRequest("Reading List does not exist");
|
||||
|
||||
var chapterIds = await _unitOfWork.VolumeRepository.GetChapterIdsByVolumeIds(dto.VolumeIds);
|
||||
foreach (var chapterId in dto.ChapterIds)
|
||||
{
|
||||
chapterIds.Add(chapterId);
|
||||
}
|
||||
|
||||
// If there are adds, tell tracking this has been modified
|
||||
if (await AddChaptersToReadingList(dto.SeriesId, chapterIds, readingList))
|
||||
{
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok("Updated");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return Ok("Nothing to do");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds all chapters from a list of series to a reading list
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-by-multiple-series")]
|
||||
public async Task<ActionResult> UpdateListByMultipleSeries(UpdateReadingListByMultipleSeriesDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserWithReadingListsByUsernameAsync(User.GetUsername());
|
||||
var readingList = user.ReadingLists.SingleOrDefault(l => l.Id == dto.ReadingListId);
|
||||
if (readingList == null) return BadRequest("Reading List does not exist");
|
||||
|
||||
var ids = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray());
|
||||
|
||||
foreach (var seriesId in ids.Keys)
|
||||
{
|
||||
// If there are adds, tell tracking this has been modified
|
||||
if (await AddChaptersToReadingList(seriesId, ids[seriesId], readingList))
|
||||
{
|
||||
_unitOfWork.ReadingListRepository.Update(readingList);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_unitOfWork.HasChanges())
|
||||
{
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok("Updated");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return Ok("Nothing to do");
|
||||
}
|
||||
|
||||
[HttpPost("update-by-volume")]
|
||||
public async Task<ActionResult> UpdateListByVolume(UpdateReadingListByVolumeDto dto)
|
||||
{
|
||||
|
9
API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs
Normal file
9
API/DTOs/Reader/MarkMultipleSeriesAsReadDto.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
public class MarkMultipleSeriesAsReadDto
|
||||
{
|
||||
public IReadOnlyList<int> SeriesIds { get; init; }
|
||||
}
|
||||
}
|
20
API/DTOs/Reader/MarkVolumesReadDto.cs
Normal file
20
API/DTOs/Reader/MarkVolumesReadDto.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.Reader
|
||||
{
|
||||
/// <summary>
|
||||
/// This is used for bulk updating a set of volume and or chapters in one go
|
||||
/// </summary>
|
||||
public class MarkVolumesReadDto
|
||||
{
|
||||
public int SeriesId { get; set; }
|
||||
/// <summary>
|
||||
/// A list of Volumes to mark read
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> VolumeIds { get; set; }
|
||||
/// <summary>
|
||||
/// A list of additional Chapters to mark as read
|
||||
/// </summary>
|
||||
public IReadOnlyList<int> ChapterIds { get; set; }
|
||||
}
|
||||
}
|
12
API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs
Normal file
12
API/DTOs/ReadingLists/UpdateReadingListByMultipleDto.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.ReadingLists
|
||||
{
|
||||
public class UpdateReadingListByMultipleDto
|
||||
{
|
||||
public int SeriesId { get; init; }
|
||||
public int ReadingListId { get; init; }
|
||||
public IReadOnlyList<int> VolumeIds { get; init; }
|
||||
public IReadOnlyList<int> ChapterIds { get; init; }
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.ReadingLists
|
||||
{
|
||||
public class UpdateReadingListByMultipleSeriesDto
|
||||
{
|
||||
public int ReadingListId { get; init; }
|
||||
public IReadOnlyList<int> SeriesIds { get; init; }
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@ -188,11 +189,16 @@ namespace API.Data.Repositories
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(int[] seriesIds)
|
||||
public async Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false)
|
||||
{
|
||||
return await _context.Volume
|
||||
.Where(v => seriesIds.Contains(v.SeriesId))
|
||||
.ToListAsync();
|
||||
var query = _context.Volume
|
||||
.Where(v => seriesIds.Contains(v.SeriesId));
|
||||
|
||||
if (includeChapters)
|
||||
{
|
||||
query = query.Include(v => v.Chapters);
|
||||
}
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteSeriesAsync(int seriesId)
|
||||
@ -237,6 +243,35 @@ namespace API.Data.Repositories
|
||||
return chapterIds.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This returns a list of tuples<chapterId, seriesId> back for each series id passed
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds)
|
||||
{
|
||||
var volumes = await _context.Volume
|
||||
.Where(v => seriesIds.Contains(v.SeriesId))
|
||||
.Include(v => v.Chapters)
|
||||
.ToListAsync();
|
||||
|
||||
var seriesChapters = new Dictionary<int, IList<int>>();
|
||||
foreach (var v in volumes)
|
||||
{
|
||||
foreach (var c in v.Chapters)
|
||||
{
|
||||
if (!seriesChapters.ContainsKey(v.SeriesId))
|
||||
{
|
||||
var list = new List<int>();
|
||||
seriesChapters.Add(v.SeriesId, list);
|
||||
}
|
||||
seriesChapters[v.SeriesId].Add(c.Id);
|
||||
}
|
||||
}
|
||||
|
||||
return seriesChapters;
|
||||
}
|
||||
|
||||
public async Task AddSeriesModifiers(int userId, List<SeriesDto> series)
|
||||
{
|
||||
var userProgress = await _context.AppUserProgresses
|
||||
|
@ -44,5 +44,13 @@ namespace API.Data.Repositories
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => volumeIds.Contains(c.VolumeId))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
@ -44,11 +45,12 @@ namespace API.Interfaces.Repositories
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
Task<VolumeDto> GetVolumeDtoAsync(int volumeId);
|
||||
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(int[] seriesIds);
|
||||
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
|
||||
Task<bool> DeleteSeriesAsync(int seriesId);
|
||||
Task<Volume> GetVolumeByIdAsync(int volumeId);
|
||||
Task<Series> GetSeriesByIdAsync(int seriesId);
|
||||
Task<int[]> GetChapterIdsForSeriesAsync(int[] seriesIds);
|
||||
Task<IDictionary<int, IList<int>>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds);
|
||||
/// <summary>
|
||||
/// Used to add Progress/Rating information to series list.
|
||||
/// </summary>
|
||||
|
@ -10,5 +10,6 @@ namespace API.Interfaces.Repositories
|
||||
void Update(Volume volume);
|
||||
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
|
||||
Task<string> GetVolumeCoverImageAsync(int volumeId);
|
||||
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IReaderService
|
||||
{
|
||||
void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters);
|
||||
Task<bool> SaveReadingProgress(ProgressDto progressDto, int userId);
|
||||
Task<int> CapPageToChapter(int chapterId, int page);
|
||||
Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId);
|
||||
|
@ -25,6 +25,99 @@ namespace API.Interfaces.Services
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Read by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="chapters"></param>
|
||||
public void MarkChaptersAsRead(AppUser user, int seriesId, IEnumerable<Chapter> chapters)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = chapter.Pages,
|
||||
VolumeId = chapter.VolumeId,
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = chapter.Pages;
|
||||
userProgress.SeriesId = seriesId;
|
||||
userProgress.VolumeId = chapter.VolumeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all Chapters as Unread by creating or updating UserProgress rows. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="chapters"></param>
|
||||
public void MarkChaptersAsUnread(AppUser user, int seriesId, IEnumerable<Chapter> chapters)
|
||||
{
|
||||
foreach (var chapter in chapters)
|
||||
{
|
||||
var userProgress = GetUserProgressForChapter(user, chapter);
|
||||
|
||||
if (userProgress == null)
|
||||
{
|
||||
user.Progresses.Add(new AppUserProgress
|
||||
{
|
||||
PagesRead = 0,
|
||||
VolumeId = chapter.VolumeId,
|
||||
SeriesId = seriesId,
|
||||
ChapterId = chapter.Id
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
userProgress.PagesRead = 0;
|
||||
userProgress.SeriesId = seriesId;
|
||||
userProgress.VolumeId = chapter.VolumeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the User Progress for a given Chapter. This will handle any duplicates that might have occured in past versions and will delete them. Does not commit.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="chapter"></param>
|
||||
/// <returns></returns>
|
||||
public static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
|
||||
{
|
||||
AppUserProgress userProgress = null;
|
||||
try
|
||||
{
|
||||
userProgress =
|
||||
user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
|
||||
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
|
||||
if (progresses.Count > 1)
|
||||
{
|
||||
user.Progresses = new List<AppUserProgress>()
|
||||
{
|
||||
user.Progresses.First()
|
||||
};
|
||||
userProgress = user.Progresses.First();
|
||||
}
|
||||
}
|
||||
|
||||
return userProgress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves progress to DB
|
||||
/// </summary>
|
||||
@ -82,6 +175,12 @@ namespace API.Interfaces.Services
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the page is within 0 and total pages for a chapter. Makes one DB call.
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <param name="page"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> CapPageToChapter(int chapterId, int page)
|
||||
{
|
||||
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
|
||||
|
@ -383,22 +383,22 @@ namespace API.Parser
|
||||
RegexTimeout),
|
||||
// Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
|
||||
new Regex(
|
||||
@"^(?<Series>.*)(?: |_)v(?<Volume>\d+)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
|
||||
new Regex(
|
||||
@"^(?<Series>.*)(?: (?<Volume>\d+))",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Batman & Robin the Teen Wonder #0
|
||||
new Regex(
|
||||
@"^(?<Series>.*)(?: |_)#(?<Volume>\d+)",
|
||||
@"^(?<Series>.+?)(?: |_)v(?<Volume>\d+)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr
|
||||
new Regex(
|
||||
@"^(?<Series>.*)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
|
||||
@"^(?<Series>.+?)(?: |_)(c? ?)(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(?: (?<Chapter>\d+))",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Batman & Robin the Teen Wonder #0
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(?:\s|_)#(?<Chapter>\d+)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Saga 001 (2012) (Digital) (Empire-Zone)
|
||||
@ -408,12 +408,12 @@ namespace API.Parser
|
||||
RegexTimeout),
|
||||
// Amazing Man Comics chapter 25
|
||||
new Regex(
|
||||
@"^(?!Vol)(?<Series>.*)( |_)c(hapter)( |_)(?<Chapter>\d*)",
|
||||
@"^(?!Vol)(?<Series>.+?)( |_)c(hapter)( |_)(?<Chapter>\d*)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
// Amazing Man Comics issue #25
|
||||
new Regex(
|
||||
@"^(?!Vol)(?<Series>.*)( |_)i(ssue)( |_) #(?<Chapter>\d*)",
|
||||
@"^(?!Vol)(?<Series>.+?)( |_)i(ssue)( |_) #(?<Chapter>\d*)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
RegexTimeout),
|
||||
};
|
||||
|
@ -20,6 +20,7 @@ export type SeriesActionCallback = (series: Series) => void;
|
||||
export type VolumeActionCallback = (volume: Volume) => void;
|
||||
export type ChapterActionCallback = (chapter: Chapter) => void;
|
||||
export type ReadingListActionCallback = (readingList: ReadingList) => void;
|
||||
export type VoidActionCallback = () => void;
|
||||
|
||||
/**
|
||||
* Responsible for executing actions
|
||||
@ -203,6 +204,85 @@ export class ActionService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all chapters and the volumes as Read. All volumes and chapters must belong to a series
|
||||
* @param seriesId Series Id
|
||||
* @param volumes Volumes, should have id, chapters and pagesRead populated
|
||||
* @param chapters? Chapters, should have id
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markMultipleAsRead(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
this.readerService.markMultipleRead(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => {
|
||||
volumes.forEach(volume => {
|
||||
volume.pagesRead = volume.pages;
|
||||
volume.chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
});
|
||||
chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
this.toastr.success('Marked as Read');
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all chapters and the volumes as Unread. All volumes must belong to a series
|
||||
* @param seriesId Series Id
|
||||
* @param volumes Volumes, should have id, chapters and pagesRead populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markMultipleAsUnread(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => {
|
||||
volumes.forEach(volume => {
|
||||
volume.pagesRead = volume.pages;
|
||||
volume.chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
});
|
||||
chapters?.forEach(c => c.pagesRead = c.pages);
|
||||
this.toastr.success('Marked as Read');
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all series as Read.
|
||||
* @param series Series, should have id, pagesRead populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markMultipleSeriesAsRead(series: Array<Series>, callback?: VoidActionCallback) {
|
||||
this.readerService.markMultipleSeriesRead(series.map(v => v.id)).pipe(take(1)).subscribe(() => {
|
||||
series.forEach(s => {
|
||||
s.pagesRead = s.pages;
|
||||
});
|
||||
this.toastr.success('Marked as Read');
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all series as Unread.
|
||||
* @param series Series, should have id, pagesRead populated
|
||||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markMultipleSeriesAsUnread(series: Array<Series>, callback?: VoidActionCallback) {
|
||||
this.readerService.markMultipleSeriesUnread(series.map(v => v.id)).pipe(take(1)).subscribe(() => {
|
||||
series.forEach(s => {
|
||||
s.pagesRead = s.pages;
|
||||
});
|
||||
this.toastr.success('Marked as Unread');
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
openBookmarkModal(series: Series, callback?: SeriesActionCallback) {
|
||||
if (this.bookmarkModalRef != null) { return; }
|
||||
@ -222,6 +302,52 @@ export class ActionService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
|
||||
this.readingListModalRef.componentInstance.seriesId = seriesId;
|
||||
this.readingListModalRef.componentInstance.volumeIds = volumes.map(v => v.id);
|
||||
this.readingListModalRef.componentInstance.chapterIds = chapters?.map(c => c.id);
|
||||
this.readingListModalRef.componentInstance.title = 'Multiple Selections';
|
||||
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple;
|
||||
|
||||
|
||||
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMultipleSeriesToReadingList(series: Array<Series>, callback?: VoidActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
|
||||
this.readingListModalRef.componentInstance.seriesIds = series.map(v => v.id);
|
||||
this.readingListModalRef.componentInstance.title = 'Multiple Selections';
|
||||
this.readingListModalRef.componentInstance.type = ADD_FLOW.Multiple_Series;
|
||||
|
||||
|
||||
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSeriesToReadingList(series: Series, callback?: SeriesActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
|
||||
|
@ -37,7 +37,7 @@ export class LibraryService {
|
||||
listDirectories(rootPath: string) {
|
||||
let query = '';
|
||||
if (rootPath !== undefined && rootPath.length > 0) {
|
||||
query = '?path=' + rootPath;
|
||||
query = '?path=' + encodeURIComponent(rootPath);
|
||||
}
|
||||
|
||||
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
|
||||
|
@ -68,9 +68,26 @@ export class ReaderService {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-read', {seriesId, volumeId});
|
||||
}
|
||||
|
||||
markMultipleRead(seriesId: number, volumeIds: Array<number>, chapterIds?: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-read', {seriesId, volumeIds, chapterIds});
|
||||
}
|
||||
|
||||
markMultipleUnread(seriesId: number, volumeIds: Array<number>, chapterIds?: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-unread', {seriesId, volumeIds, chapterIds});
|
||||
}
|
||||
|
||||
markMultipleSeriesRead(seriesIds: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-series-read', {seriesIds});
|
||||
}
|
||||
|
||||
markMultipleSeriesUnread(seriesIds: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-multiple-series-unread', {seriesIds});
|
||||
}
|
||||
|
||||
markVolumeUnread(seriesId: number, volumeId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/mark-volume-unread', {seriesId, volumeId});
|
||||
}
|
||||
|
||||
|
||||
getNextChapter(seriesId: number, volumeId: number, currentChapterId: number, readingListId: number = -1) {
|
||||
if (readingListId > 0) {
|
||||
|
@ -42,6 +42,14 @@ export class ReadingListService {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/update', model, { responseType: 'text' as 'json' });
|
||||
}
|
||||
|
||||
updateByMultiple(readingListId: number, seriesId: number, volumeIds: Array<number>, chapterIds?: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple', {readingListId, seriesId, volumeIds, chapterIds});
|
||||
}
|
||||
|
||||
updateByMultipleSeries(readingListId: number, seriesIds: Array<number>) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-multiple-series', {readingListId, seriesIds});
|
||||
}
|
||||
|
||||
updateBySeries(readingListId: number, seriesId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/update-by-series', {readingListId, seriesId}, { responseType: 'text' as 'json' });
|
||||
}
|
||||
|
@ -0,0 +1,8 @@
|
||||
<div *ngIf="bulkSelectionService.hasSelections()" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
|
||||
<div class="d-flex justify-content-around align-items-center">
|
||||
<span class="highlight"><i class="fa fa-check" aria-hidden="true"></i> {{bulkSelectionService.totalSelections()}} selected</span>
|
||||
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables>
|
||||
<span id="bulk-actions-header" class="sr-only">Bulk Actions</span>
|
||||
<button class="btn btn-icon" (click)="bulkSelectionService.deselectAll()"><i class="fa fa-times" aria-hidden="true"></i> Deselect All</button>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,16 @@
|
||||
@import "../../../theme/colors";
|
||||
@import "../../../assets/themes/dark.scss";
|
||||
|
||||
.bulk-select {
|
||||
background-color: $dark-form-background-no-opacity;
|
||||
border-bottom: 2px solid $primary-color;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: $primary-color !important;
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-operations',
|
||||
templateUrl: './bulk-operations.component.html',
|
||||
styleUrls: ['./bulk-operations.component.scss']
|
||||
})
|
||||
export class BulkOperationsComponent implements OnInit {
|
||||
|
||||
@Input() actionCallback!: (action: Action, data: any) => void;
|
||||
topOffset: number = 0;
|
||||
|
||||
get actions() {
|
||||
return this.bulkSelectionService.getActions(this.actionCallback.bind(this));
|
||||
}
|
||||
|
||||
constructor(public bulkSelectionService: BulkSelectionService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
const navBar = document.querySelector('.navbar');
|
||||
if (navBar) {
|
||||
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height);
|
||||
}
|
||||
}
|
||||
|
||||
handleActionCallback(action: Action, data: any) {
|
||||
this.actionCallback(action, data);
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action.action, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
147
UI/Web/src/app/cards/bulk-selection.service.ts
Normal file
147
UI/Web/src/app/cards/bulk-selection.service.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { NavigationStart, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Action, ActionFactoryService } from '../_services/action-factory.service';
|
||||
|
||||
type DataSource = 'volume' | 'chapter' | 'special' | 'series';
|
||||
|
||||
/**
|
||||
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
|
||||
* This will clear selections between pages.
|
||||
*
|
||||
* Remakrs: Page which renders cards is responsible for listening for shift keydown/keyup and updating our state variable.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class BulkSelectionService {
|
||||
|
||||
private debug: boolean = false;
|
||||
private prevIndex: number = 0;
|
||||
private prevDataSource!: DataSource;
|
||||
private selectedCards: { [key: string]: {[key: number]: boolean} } = {};
|
||||
private dataSourceMax: { [key: string]: number} = {};
|
||||
public isShiftDown: boolean = false;
|
||||
|
||||
constructor(private router: Router, private actionFactory: ActionFactoryService) {
|
||||
router.events
|
||||
.pipe(filter(event => event instanceof NavigationStart))
|
||||
.subscribe((event) => {
|
||||
this.deselectAll();
|
||||
this.dataSourceMax = {};
|
||||
this.prevIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
handleCardSelection(dataSource: DataSource, index: number, maxIndex: number, wasSelected: boolean) {
|
||||
if (this.isShiftDown) {
|
||||
|
||||
if (dataSource === this.prevDataSource) {
|
||||
this.debugLog('Selecting ' + dataSource + ' cards from ' + this.prevIndex + ' to ' + index);
|
||||
this.selectCards(dataSource, this.prevIndex, index, !wasSelected);
|
||||
} else {
|
||||
const isForwardSelection = index < this.prevIndex;
|
||||
|
||||
if (isForwardSelection) {
|
||||
this.debugLog('Selecting ' + this.prevDataSource + ' cards from ' + this.prevIndex + ' to ' + this.dataSourceMax[this.prevDataSource]);
|
||||
this.selectCards(this.prevDataSource, this.prevIndex, this.dataSourceMax[this.prevDataSource], !wasSelected);
|
||||
this.debugLog('Selecting ' + dataSource + ' cards from ' + 0 + ' to ' + index);
|
||||
this.selectCards(dataSource, 0, index, !wasSelected);
|
||||
} else {
|
||||
this.debugLog('Selecting ' + this.prevDataSource + ' cards from ' + 0 + ' to ' + this.prevIndex);
|
||||
this.selectCards(this.prevDataSource, this.prevIndex, 0, !wasSelected);
|
||||
this.debugLog('Selecting ' + dataSource + ' cards from ' + index + ' to ' + maxIndex);
|
||||
this.selectCards(dataSource, index, maxIndex, !wasSelected);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.debugLog('Selecting ' + dataSource + ' cards at ' + index);
|
||||
this.selectCards(dataSource, index, index, !wasSelected);
|
||||
}
|
||||
this.prevIndex = index;
|
||||
this.prevDataSource = dataSource;
|
||||
this.dataSourceMax[dataSource] = maxIndex;
|
||||
}
|
||||
|
||||
isCardSelected(dataSource: DataSource, index: number) {
|
||||
if (this.selectedCards.hasOwnProperty(dataSource) && this.selectedCards[dataSource].hasOwnProperty(index)) {
|
||||
return this.selectedCards[dataSource][index];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
selectCards(dataSource: DataSource, from: number, to: number, value: boolean) {
|
||||
if (!this.selectedCards.hasOwnProperty(dataSource)) {
|
||||
this.selectedCards[dataSource] = {};
|
||||
}
|
||||
|
||||
if (from === to) {
|
||||
this.selectedCards[dataSource][to] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (from > to) {
|
||||
for (let i = to; i <= from; i++) {
|
||||
this.selectedCards[dataSource][i] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = from; i <= to; i++) {
|
||||
this.selectedCards[dataSource][i] = value;
|
||||
}
|
||||
}
|
||||
|
||||
deselectAll() {
|
||||
this.selectedCards = {};
|
||||
}
|
||||
|
||||
hasSelections() {
|
||||
const keys = Object.keys(this.selectedCards);
|
||||
return keys.filter(key => {
|
||||
return Object.values(this.selectedCards[key]).filter(item => item).length > 0;
|
||||
}).length > 0;
|
||||
}
|
||||
|
||||
totalSelections() {
|
||||
let sum = 0;
|
||||
const keys = Object.keys(this.selectedCards);
|
||||
keys.forEach(key => {
|
||||
sum += Object.values(this.selectedCards[key]).filter(item => item).length;
|
||||
});
|
||||
return sum;
|
||||
}
|
||||
|
||||
getSelectedCardsForSource(dataSource: DataSource) {
|
||||
if (!this.selectedCards.hasOwnProperty(dataSource)) return [];
|
||||
|
||||
let ret = [];
|
||||
for(let k in this.selectedCards[dataSource]) {
|
||||
if (this.selectedCards[dataSource][k]) {
|
||||
ret.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
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];
|
||||
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
|
||||
return this.actionFactory.getSeriesActions(callback).filter(item => allowedActions.includes(item.action));
|
||||
}
|
||||
|
||||
return this.actionFactory.getVolumeActions(callback).filter(item => allowedActions.includes(item.action));
|
||||
}
|
||||
|
||||
private debugLog(message: string, extraData?: any) {
|
||||
if (!this.debug) return;
|
||||
|
||||
if (extraData !== undefined) {
|
||||
console.log(message, extraData);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
}
|
@ -39,7 +39,4 @@ export class CardActionablesComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Insert hr to separate admin actions
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="card">
|
||||
<div class="overlay" (click)="handleClick()">
|
||||
<div class="overlay" (click)="handleClick($event)">
|
||||
<img *ngIf="total > 0 || supressArchiveWarning" class="img-top lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
|
||||
(error)="imageService.updateErroredImage($event)" aria-hidden="true" height="230px" width="158px">
|
||||
<img *ngIf="total === 0 && !supressArchiveWarning" class="img-top lazyload" [src]="imageService.errorImage" [attr.data-src]="imageUrl"
|
||||
@ -17,20 +17,22 @@
|
||||
<div class="error-banner" *ngIf="total === 0 && !supressArchiveWarning">
|
||||
Cannot Read
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<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" attr.aria-labelledby="{{title}}_{{entity.id}}" [ngModel]="selected" [ngModelOptions]="{standalone: true}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">
|
||||
<div>
|
||||
<span class="card-title" placement="top" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
|
||||
<span class="card-title" placement="top" id="{{title}}_{{entity.id}}" ngbTooltip="{{title}}" (click)="handleClick()" tabindex="0">
|
||||
<span *ngIf="isPromoted()">
|
||||
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
<i class="fa {{utilityService.mangaFormatIcon(format)}}" aria-hidden="true" *ngIf="format != MangaFormat.UNKNOWN" title="{{utilityService.mangaFormat(format)}}"></i><span class="sr-only">{{utilityService.mangaFormat(format)}}</span>
|
||||
{{title}}
|
||||
<span class="sr-only">(promoted)</span>
|
||||
</span>
|
||||
<span class="card-actions float-right">
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables>
|
||||
|
@ -38,6 +38,8 @@ $image-width: 160px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.img-top {
|
||||
height: $image-height;
|
||||
}
|
||||
@ -71,12 +73,36 @@ $image-width: 160px;
|
||||
border-color: transparent $primary-color transparent transparent;
|
||||
}
|
||||
|
||||
|
||||
.bulk-mode {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
visibility: hidden;
|
||||
|
||||
&.always-show {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.overlay {
|
||||
height: $image-height;
|
||||
|
||||
|
||||
|
||||
&:hover {
|
||||
visibility: visible;
|
||||
|
||||
.bulk-mode {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.overlay-item {
|
||||
visibility: visible;
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ import { Volume } from 'src/app/_models/volume';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
@ -21,23 +22,71 @@ import { LibraryService } from 'src/app/_services/library.service';
|
||||
})
|
||||
export class CardItemComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Card item url. Will internally handle error and missing covers
|
||||
*/
|
||||
@Input() imageUrl = '';
|
||||
/**
|
||||
* Name of the card
|
||||
*/
|
||||
@Input() title = '';
|
||||
/**
|
||||
* Any actions to perform on the card
|
||||
*/
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() read = 0; // Pages read
|
||||
@Input() total = 0; // Total Pages
|
||||
/**
|
||||
* Pages Read
|
||||
*/
|
||||
@Input() read = 0;
|
||||
/**
|
||||
* Total Pages
|
||||
*/
|
||||
@Input() total = 0;
|
||||
/**
|
||||
* Supress library link
|
||||
*/
|
||||
@Input() supressLibraryLink = false;
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag; // This is the entity we are representing. It will be returned if an action is executed.
|
||||
/**
|
||||
* This is the entity we are representing. It will be returned if an action is executed.
|
||||
*/
|
||||
@Input() entity!: Series | Volume | Chapter | CollectionTag;
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
@Input() selected: boolean = false;
|
||||
/**
|
||||
* If the entity should show selection code
|
||||
*/
|
||||
@Input() allowSelection: boolean = false;
|
||||
/**
|
||||
* Event emitted when item is clicked
|
||||
*/
|
||||
@Output() clicked = new EventEmitter<string>();
|
||||
|
||||
libraryName: string | undefined = undefined; // Library name item belongs to
|
||||
/**
|
||||
* When the card is selected.
|
||||
*/
|
||||
@Output() selection = new EventEmitter<boolean>();
|
||||
/**
|
||||
* Library name item belongs to
|
||||
*/
|
||||
libraryName: string | undefined = undefined;
|
||||
libraryId: number | undefined = undefined;
|
||||
supressArchiveWarning: boolean = false; // This will supress the cannot read archive warning when total pages is 0
|
||||
/**
|
||||
* This will supress the cannot read archive warning when total pages is 0
|
||||
*/
|
||||
supressArchiveWarning: boolean = false;
|
||||
/**
|
||||
* Format of the entity (only applies to Series)
|
||||
*/
|
||||
format: MangaFormat = MangaFormat.UNKNOWN;
|
||||
|
||||
|
||||
download$: Observable<Download> | null = null;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
isShiftDown: boolean = false;
|
||||
|
||||
|
||||
get MangaFormat(): typeof MangaFormat {
|
||||
return MangaFormat;
|
||||
}
|
||||
@ -46,7 +95,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(public imageService: ImageService, private libraryService: LibraryService,
|
||||
public utilityService: UtilityService, private downloadService: DownloadService,
|
||||
private toastr: ToastrService) {}
|
||||
private toastr: ToastrService, public bulkSelectionService: BulkSelectionService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
|
||||
@ -69,7 +118,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
handleClick(event?: any) {
|
||||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
@ -146,7 +195,14 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
|
||||
isPromoted() {
|
||||
const tag = this.entity as CollectionTag;
|
||||
// TODO: Validate if this works with reading lists
|
||||
return tag.hasOwnProperty('promoted') && tag.promoted;
|
||||
}
|
||||
|
||||
|
||||
handleSelection(event?: any) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
this.selection.emit(this.selected);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import { TypeaheadModule } from '../typeahead/typeahead.module';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layout.component';
|
||||
import { CardDetailsModalComponent } from './_modals/card-details-modal/card-details-modal.component';
|
||||
import { BulkOperationsComponent } from './bulk-operations/bulk-operations.component';
|
||||
|
||||
|
||||
|
||||
@ -34,7 +35,8 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent
|
||||
CardDetailsModalComponent,
|
||||
BulkOperationsComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -70,7 +72,8 @@ import { CardDetailsModalComponent } from './_modals/card-details-modal/card-det
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent
|
||||
CardDetailsModalComponent,
|
||||
BulkOperationsComponent
|
||||
]
|
||||
})
|
||||
export class CardsModule { }
|
||||
|
@ -1,3 +1,6 @@
|
||||
<ng-container *ngIf="data !== undefined">
|
||||
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl" [entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"></app-card-item>
|
||||
<app-card-item [title]="data.name" [actions]="actions" [supressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl"
|
||||
[entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"
|
||||
[allowSelection]="allowSelection" (selection)="selection.emit(selected)" [selected]="selected"
|
||||
></app-card-item>
|
||||
</ng-container>
|
@ -21,9 +21,22 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
@Input() data!: Series;
|
||||
@Input() libraryId = 0;
|
||||
@Input() suppressLibraryLink = false;
|
||||
/**
|
||||
* If the entity is selected or not.
|
||||
*/
|
||||
@Input() selected: boolean = false;
|
||||
/**
|
||||
* If the entity should show selection code
|
||||
*/
|
||||
@Input() allowSelection: boolean = false;
|
||||
|
||||
@Output() clicked = new EventEmitter<Series>();
|
||||
@Output() reload = new EventEmitter<boolean>();
|
||||
@Output() dataChanged = new EventEmitter<Series>();
|
||||
/**
|
||||
* When the card is selected.
|
||||
*/
|
||||
@Output() selection = new EventEmitter<boolean>();
|
||||
|
||||
isAdmin = false;
|
||||
actions: ActionItem<Series>[] = [];
|
||||
|
@ -11,14 +11,6 @@
|
||||
</h2>
|
||||
</div>
|
||||
<div class="row no-gutters mt-2 mb-2">
|
||||
<!-- <div>
|
||||
<button class="btn btn-primary" (click)="read()" [disabled]="isLoading">
|
||||
<span>
|
||||
<i class="fa fa-book-open"></i>
|
||||
</span>
|
||||
<span class="read-btn--text"> Read</span>
|
||||
</button>
|
||||
</div> -->
|
||||
<div class="ml-2" *ngIf="isAdmin">
|
||||
<button class="btn btn-secondary" (click)="openEditCollectionTagModal(collectionTag)" title="Edit Series information">
|
||||
<span>
|
||||
@ -33,6 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
|
||||
<app-card-detail-layout
|
||||
header="Series"
|
||||
@ -44,7 +37,9 @@
|
||||
(applyFilter)="updateFilter($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()"></app-series-card>
|
||||
<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>
|
||||
</app-card-detail-layout>
|
||||
|
||||
|
@ -1,19 +1,20 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, HostListener, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component';
|
||||
import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { CollectionTag } from 'src/app/_models/collection-tag';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { Pagination } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ActionService } from 'src/app/_services/action.service';
|
||||
import { CollectionTagService } from 'src/app/_services/collection-tag.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
@ -39,9 +40,31 @@ export class CollectionDetailComponent implements OnInit {
|
||||
mangaFormat: null
|
||||
};
|
||||
|
||||
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.AddToReadingList:
|
||||
this.actionService.addMultipleSeriesToReadingList(selectedSeries);
|
||||
break;
|
||||
case Action.MarkAsRead:
|
||||
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute,
|
||||
private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService,
|
||||
private modalService: NgbModal, private titleService: Title, private accountService: AccountService, private utilityService: UtilityService) {
|
||||
private modalService: NgbModal, private titleService: Title, private accountService: AccountService,
|
||||
public bulkSelectionService: BulkSelectionService, private actionService: ActionService) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
@ -63,6 +86,20 @@ export class CollectionDetailComponent implements OnInit {
|
||||
this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this));
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
updateTag(tagId: number) {
|
||||
this.collectionService.allTags().subscribe(tags => {
|
||||
this.collections = tags;
|
||||
|
@ -1,3 +1,4 @@
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout header="{{libraryName}}"
|
||||
[isLoading]="loadingSeries"
|
||||
[items]="series"
|
||||
@ -8,6 +9,6 @@
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()"></app-series-card>
|
||||
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, HostListener, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component';
|
||||
import { KEY_CODES } from '../shared/_services/utility.service';
|
||||
import { Library } from '../_models/library';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
@ -30,9 +32,39 @@ export class LibraryDetailComponent implements OnInit {
|
||||
mangaFormat: null
|
||||
};
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
console.log('handling bulk action callback');
|
||||
// we need to figure out what is actually selected now
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||
|
||||
switch (action) {
|
||||
case Action.AddToReadingList:
|
||||
this.actionService.addMultipleSeriesToReadingList(selectedSeries);
|
||||
break;
|
||||
case Action.MarkAsRead:
|
||||
console.log('marking series as read: ', selectedSeries)
|
||||
|
||||
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
});
|
||||
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
//console.log('marking volumes as unread: ', selectedVolumeIds)
|
||||
//console.log('marking chapters as unread: ', chapters)
|
||||
|
||||
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService,
|
||||
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService) {
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService) {
|
||||
const routeId = this.route.snapshot.paramMap.get('id');
|
||||
if (routeId === null) {
|
||||
this.router.navigateByUrl('/libraries');
|
||||
@ -53,6 +85,20 @@ export class LibraryDetailComponent implements OnInit {
|
||||
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
handleAction(action: Action, library: Library) {
|
||||
let lib: Partial<Library> = library;
|
||||
if (library === undefined) {
|
||||
|
@ -1,13 +1,17 @@
|
||||
import { noUndefined } from '@angular/compiler/src/util';
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { ReadingList } from 'src/app/_models/reading-list';
|
||||
import { ReadingListService } from 'src/app/_services/reading-list.service';
|
||||
|
||||
export enum ADD_FLOW {
|
||||
Series = 0,
|
||||
Volume = 1,
|
||||
Chapter = 2
|
||||
Chapter = 2,
|
||||
Multiple = 3,
|
||||
Multiple_Series
|
||||
}
|
||||
|
||||
@Component({
|
||||
@ -18,9 +22,31 @@ export enum ADD_FLOW {
|
||||
export class AddToListModalComponent implements OnInit, AfterViewInit {
|
||||
|
||||
@Input() title!: string;
|
||||
/**
|
||||
* Only used in Series flow
|
||||
*/
|
||||
@Input() seriesId?: number;
|
||||
/**
|
||||
* Only used in Volume flow
|
||||
*/
|
||||
@Input() volumeId?: number;
|
||||
/**
|
||||
* Only used in Chapter flow
|
||||
*/
|
||||
@Input() chapterId?: number;
|
||||
/**
|
||||
* Only used in Multiple flow
|
||||
*/
|
||||
@Input() volumeIds?: Array<number>;
|
||||
/**
|
||||
* Only used in Multiple flow
|
||||
*/
|
||||
@Input() chapterIds?: Array<number>;
|
||||
/**
|
||||
* Only used in Multiple_Series flow
|
||||
*/
|
||||
@Input() seriesIds?: Array<number>;
|
||||
|
||||
/**
|
||||
* Determines which Input is required and which API is used to associate to the Reading List
|
||||
*/
|
||||
@ -36,7 +62,7 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
|
||||
|
||||
|
||||
constructor(private modal: NgbActiveModal, private readingListService: ReadingListService) { }
|
||||
constructor(private modal: NgbActiveModal, private readingListService: ReadingListService, private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@ -70,18 +96,34 @@ export class AddToListModalComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
|
||||
addToList(readingList: ReadingList) {
|
||||
|
||||
if (this.type === ADD_FLOW.Multiple_Series && this.seriesIds !== undefined) {
|
||||
this.readingListService.updateByMultipleSeries(readingList.id, this.seriesIds).subscribe(() => {
|
||||
this.toastr.success('Series added to reading list');
|
||||
this.modal.close();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.seriesId === undefined) return;
|
||||
|
||||
if (this.type === ADD_FLOW.Series) {
|
||||
if (this.type === ADD_FLOW.Series && this.seriesId !== undefined) {
|
||||
this.readingListService.updateBySeries(readingList.id, this.seriesId).subscribe(() => {
|
||||
this.toastr.success('Series added to reading list');
|
||||
this.modal.close();
|
||||
});
|
||||
} else if (this.type === ADD_FLOW.Volume && this.volumeId !== undefined) {
|
||||
this.readingListService.updateByVolume(readingList.id, this.seriesId, this.volumeId).subscribe(() => {
|
||||
this.toastr.success('Volumes added to reading list');
|
||||
this.modal.close();
|
||||
});
|
||||
} else if (this.type === ADD_FLOW.Chapter && this.chapterId !== undefined) {
|
||||
this.readingListService.updateByChapter(readingList.id, this.seriesId, this.chapterId).subscribe(() => {
|
||||
this.toastr.success('Chapter added to reading list');
|
||||
this.modal.close();
|
||||
});
|
||||
} else if (this.type === ADD_FLOW.Multiple && this.volumeIds !== undefined && this.chapterIds !== undefined) {
|
||||
this.readingListService.updateByMultiple(readingList.id, this.seriesId, this.volumeIds, this.chapterIds).subscribe(() => {
|
||||
this.toastr.success('Chapters and Volumes added to reading list');
|
||||
this.modal.close();
|
||||
});
|
||||
}
|
||||
|
@ -97,15 +97,16 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav-tabs nav-pills" [destroyOnHide]="false">
|
||||
<li [ngbNavItem]="1" *ngIf="hasSpecials">
|
||||
<a ngbNavLink>Specials</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row">
|
||||
<div *ngFor="let chapter of specials; trackBy: trackByChapterIdentity">
|
||||
<div *ngFor="let chapter of specials; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="chapter.isSpecial" [entity]="chapter" [title]="chapter.title || chapter.range" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('special', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
@ -114,16 +115,16 @@
|
||||
<a ngbNavLink>Volumes/Chapters</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row">
|
||||
<div *ngFor="let volume of volumes; trackBy: trackByVolumeIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
|
||||
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions"></app-card-item>
|
||||
</div>
|
||||
<div *ngFor="let chapter of chapters; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions"></app-card-item>
|
||||
</div>
|
||||
<div *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="volume.number != 0" [entity]="volume" [title]="'Volume ' + volume.name" (click)="openVolume(volume)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
|
||||
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
<div *ngFor="let chapter of chapters; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="'Chapter ' + chapter.range" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
@ -1,17 +1,18 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
|
||||
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
|
||||
import { ConfirmService } from '../shared/confirm.service';
|
||||
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
|
||||
import { DownloadService } from '../shared/_services/download.service';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
|
||||
@ -53,6 +54,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
seriesActions: ActionItem<Series>[] = [];
|
||||
volumeActions: ActionItem<Volume>[] = [];
|
||||
chapterActions: ActionItem<Chapter>[] = [];
|
||||
bulkActions: ActionItem<any>[] = [];
|
||||
|
||||
hasSpecials = false;
|
||||
specials: Array<Chapter> = [];
|
||||
@ -88,6 +90,48 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
console.log('handling bulk action callback');
|
||||
if (this.series === undefined) {
|
||||
return;
|
||||
}
|
||||
const seriesId = this.series.id;
|
||||
// we need to figure out what is actually selected now
|
||||
const selectedVolumeIndexes = this.bulkSelectionService.getSelectedCardsForSource('volume');
|
||||
const selectedChapterIndexes = this.bulkSelectionService.getSelectedCardsForSource('chapter');
|
||||
const selectedSpecialIndexes = this.bulkSelectionService.getSelectedCardsForSource('special');
|
||||
|
||||
const selectedChapterIds = this.chapters.filter((chapter, index: number) => selectedChapterIndexes.includes(index + ''));
|
||||
const selectedVolumeIds = this.volumes.filter((volume, index: number) => selectedVolumeIndexes.includes(index + ''));
|
||||
const selectedSpecials = this.specials.filter((chapter, index: number) => selectedSpecialIndexes.includes(index + ''));
|
||||
const chapters = [...selectedChapterIds, ...selectedSpecials];
|
||||
|
||||
switch (action) {
|
||||
case Action.AddToReadingList:
|
||||
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, () => this.actionInProgress = false);
|
||||
break;
|
||||
case Action.MarkAsRead:
|
||||
console.log('marking volumes as read: ', selectedVolumeIds)
|
||||
console.log('marking chapters as read: ', chapters)
|
||||
|
||||
this.actionService.markMultipleAsRead(seriesId, selectedVolumeIds, chapters, () => {
|
||||
this.setContinuePoint();
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
console.log('marking volumes as unread: ', selectedVolumeIds)
|
||||
console.log('marking chapters as unread: ', chapters)
|
||||
|
||||
this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => {
|
||||
this.setContinuePoint();
|
||||
this.actionInProgress = false;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
|
||||
@ -111,7 +155,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
private actionFactoryService: ActionFactoryService, private libraryService: LibraryService,
|
||||
private confirmService: ConfirmService, private titleService: Title,
|
||||
private downloadService: DownloadService, private actionService: ActionService,
|
||||
public imageSerivce: ImageService, private messageHub: MessageHubService) {
|
||||
public imageSerivce: ImageService, private messageHub: MessageHubService,
|
||||
public bulkSelectionService: BulkSelectionService) {
|
||||
ratingConfig.max = 5;
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
@ -151,6 +196,20 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.onDestroy.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;
|
||||
}
|
||||
}
|
||||
|
||||
handleSeriesActionCallback(action: Action, series: Series) {
|
||||
this.actionInProgress = true;
|
||||
switch(action) {
|
||||
@ -281,6 +340,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
||||
|
||||
|
||||
|
||||
this.seriesService.getVolumes(this.series.id).subscribe(volumes => {
|
||||
this.chapters = volumes.filter(v => v.number === 0).map(v => v.chapters || []).flat().sort(this.utilityService.sortChapters);
|
||||
@ -490,7 +550,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
downloadSeries() {
|
||||
|
||||
this.downloadService.downloadSeriesSize(this.series.id).pipe(take(1)).subscribe(async (size) => {
|
||||
const wantToDownload = await this.downloadService.confirmSize(size, 'series');
|
||||
if (!wantToDownload) { return; }
|
||||
|
@ -15,7 +15,8 @@ export enum KEY_CODES {
|
||||
G = 'g',
|
||||
B = 'b',
|
||||
BACKSPACE = 'Backspace',
|
||||
DELETE = 'Delete'
|
||||
DELETE = 'Delete',
|
||||
SHIFT = 'Shift'
|
||||
}
|
||||
|
||||
export enum Breakpoint {
|
||||
|
Loading…
x
Reference in New Issue
Block a user