From 2d59580aef99ee36595917f5176b74a063bdf9b1 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 29 Jan 2022 08:04:18 -0800 Subject: [PATCH] Recently Added Chapters/Volumes (#1007) * Working on adding recently added chapter/volumes to dashboard. Have some progress, need to tweak grouping logic. * Tweaked the logic to work well for grouping. Now to incorporate information for UI to provide seamless integration * Implemented UI part for Recently Added. --- API/Controllers/SeriesController.cs | 7 + API/DTOs/RecentlyAddedItemDto.cs | 34 ++++ API/Data/Repositories/SeriesRepository.cs | 148 ++++++++++++++++++ UI/Web/src/app/_models/in-progress-chapter.ts | 13 -- UI/Web/src/app/_models/recently-added-item.ts | 13 ++ UI/Web/src/app/_services/image.service.ts | 8 + UI/Web/src/app/_services/series.service.ts | 13 +- .../cards/card-item/card-item.component.html | 1 + .../cards/card-item/card-item.component.ts | 15 +- UI/Web/src/app/library/library.component.html | 9 +- UI/Web/src/app/library/library.component.ts | 15 +- 11 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 API/DTOs/RecentlyAddedItemDto.cs delete mode 100644 UI/Web/src/app/_models/in-progress-chapter.ts create mode 100644 UI/Web/src/app/_models/recently-added-item.ts diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 6787c9142..e3f75332c 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -237,6 +237,13 @@ namespace API.Controllers return Ok(series); } + [HttpPost("recently-added-chapters")] + public async Task>> GetRecentlyAddedChapters() + { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetRecentlyAddedChapters(userId)); + } + [HttpPost("all")] public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) { diff --git a/API/DTOs/RecentlyAddedItemDto.cs b/API/DTOs/RecentlyAddedItemDto.cs new file mode 100644 index 000000000..6c7df8b4d --- /dev/null +++ b/API/DTOs/RecentlyAddedItemDto.cs @@ -0,0 +1,34 @@ +using System; +using API.Entities.Enums; + +namespace API.DTOs; + +/// +/// A mesh of data for Recently added volume/chapters +/// +public class RecentlyAddedItemDto +{ + public string SeriesName { get; set; } + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + /// + /// This will automatically map to Volume X, Chapter Y, etc. + /// + public string Title { get; set; } + public DateTime Created { get; set; } + /// + /// Chapter Id if this is a chapter. Not guaranteed to be set. + /// + public int ChapterId { get; set; } = 0; + /// + /// Volume Id if this is a chapter. Not guaranteed to be set. + /// + public int VolumeId { get; set; } = 0; + /// + /// This is used only on the UI. It is just index of being added. + /// + public int Id { get; set; } + public MangaFormat Format { get; set; } + +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index e8ffa9e16..89befac86 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -73,6 +73,7 @@ public interface ISeriesRepository Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); Task> GetAllLanguagesForLibrariesAsync(List libraryIds); Task> GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); + Task> GetRecentlyAddedChapters(int userId); } public class SeriesRepository : ISeriesRepository @@ -802,4 +803,151 @@ public class SeriesRepository : ISeriesRepository }) .ToListAsync(); } + + public async Task> GetRecentlyAddedChapters(int userId) + { + var libraries = await _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(u => u.Libraries.Select(l => new {LibraryId = l.Id, LibraryType = l.Type})) + .ToListAsync(); + var libraryIds = libraries.Select(l => l.LibraryId).ToList(); + + var withinLastWeek = DateTime.Now - TimeSpan.FromDays(12); + + var ret = await _context.Series + .Where(s => libraryIds.Contains(s.LibraryId) && s.LastModified >= withinLastWeek) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .Select(s => new + { + s.LibraryId, + LibraryType = s.Library.Type, + s.Created, + SeriesId = s.Id, + SeriesName = s.Name, + Series = s, + Chapters = s.Volumes.SelectMany(v => v.Chapters) + }) + .Take(50) + .AsNoTracking() + .AsSplitQuery() + .OrderByDescending(item => item.Created) + .ToListAsync(); + + var items = new List(); + foreach (var series in ret) + { + if (items.Count >= 50) return items; + var chaptersThatMeetCutoff = series.Chapters.Where(c => c.Created >= withinLastWeek) + .OrderByDescending(c => c.Created); + var chapterMap = chaptersThatMeetCutoff.GroupBy(c => c.VolumeId) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var (volumeId, chapters) in chapterMap) + { + // If a single chapter + if (chapters.Count == 1) + { + // Create a chapter ReadingListItemDto + var chapterTitle = "Chapter"; + switch (series.LibraryType) + { + case LibraryType.Book: + chapterTitle = ""; + break; + case LibraryType.Comic: + chapterTitle = "Issue"; + break; + } + + // If chapter is 0, then it means it's really a volume, so show it that way + var firstChapter = chapters.First(); + string title; + if (firstChapter.Number.Equals(Parser.Parser.DefaultChapter)) + { + title = "Volume " + series.Series.Volumes.FirstOrDefault(v => v.Id == volumeId)?.Number; + } + else + { + title = chapters.First().IsSpecial + ? chapters.FirstOrDefault()?.Range + : $"{chapterTitle} {chapters.FirstOrDefault()?.Range}"; + } + + items.Add(new RecentlyAddedItemDto() + { + LibraryId = series.LibraryId, + LibraryType = series.LibraryType, + SeriesId = series.SeriesId, + SeriesName = series.SeriesName, + Created = chapters.Max(c => c.Created), + Title = title, + ChapterId = firstChapter.Id, + Id = items.Count, + Format = series.Series.Format + }); + if (items.Count >= 50) return items; + continue; + } + + + // Multiple chapters, so let's show as a volume + var volumeNumber = series.Series.Volumes.FirstOrDefault(v => v.Id == volumeId)?.Number; + if (volumeNumber == 0) + { + var volumeChapters = chapters.Where(c => c.Created >= withinLastWeek).ToList(); + foreach (var chap in volumeChapters) + { + // Create a chapter ReadingListItemDto + var chapterTitle = "Chapter"; + switch (series.LibraryType) + { + case LibraryType.Book: + chapterTitle = ""; + break; + case LibraryType.Comic: + chapterTitle = "Issue"; + break; + } + + var title = volumeChapters.First().IsSpecial + ? volumeChapters.FirstOrDefault()?.Range + : $"{chapterTitle} {volumeChapters.FirstOrDefault()?.Range}"; + items.Add(new RecentlyAddedItemDto() + { + LibraryId = series.LibraryId, + LibraryType = series.LibraryType, + SeriesId = series.SeriesId, + SeriesName = series.SeriesName, + Created = chap.Created, + Title = title, + ChapterId = chap.Id, + Id = items.Count, + Format = series.Series.Format + }); + if (items.Count >= 50) return items; + } + continue; + } + + // Create a volume ReadingListItemDto + var theVolume = series.Series.Volumes.First(v => v.Id == volumeId); + items.Add(new RecentlyAddedItemDto() + { + LibraryId = series.LibraryId, + LibraryType = series.LibraryType, + SeriesId = series.SeriesId, + SeriesName = series.SeriesName, + Created = chapters.Max(c => c.Created), + Title = "Volume " + theVolume.Number, + VolumeId = theVolume.Id, + Id = items.Count, + Format = series.Series.Format + }); + if (items.Count >= 50) return items; + } + } + + return items; + } } diff --git a/UI/Web/src/app/_models/in-progress-chapter.ts b/UI/Web/src/app/_models/in-progress-chapter.ts deleted file mode 100644 index 57e75b0ed..000000000 --- a/UI/Web/src/app/_models/in-progress-chapter.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface InProgressChapter { - id: number; - range: string; - number: string; - pages: number; - volumeId: number; - pagesRead: number; - seriesId: number; - seriesName: string; - coverImage: string; - libraryId: number; - libraryName: string; -} diff --git a/UI/Web/src/app/_models/recently-added-item.ts b/UI/Web/src/app/_models/recently-added-item.ts new file mode 100644 index 000000000..f60b83dc6 --- /dev/null +++ b/UI/Web/src/app/_models/recently-added-item.ts @@ -0,0 +1,13 @@ +import { LibraryType } from "./library"; + +export interface RecentlyAddedItem { + seriesId: number; + seriesName: string; + created: string; + title: string; + libraryId: number; + libraryType: LibraryType; + volumeId: number; + chapterId: number; + id: number; // This is UI only +} \ No newline at end of file diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 4cb21c299..b45896206 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -2,6 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; import { AccountService } from './account.service'; import { NavService } from './nav.service'; @@ -41,6 +42,13 @@ export class ImageService implements OnDestroy { this.onDestroy.complete(); } + getRecentlyAddedItem(item: RecentlyAddedItem) { + if (item.chapterId === 0) { + return this.getVolumeCoverImage(item.volumeId); + } + return this.getChapterCoverImage(item.chapterId); + } + getVolumeCoverImage(volumeId: number) { return this.baseUrl + 'image/volume-cover?volumeId=' + volumeId; } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index ddd8abf2f..e6a35a775 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -5,10 +5,10 @@ import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Chapter } from '../_models/chapter'; import { CollectionTag } from '../_models/collection-tag'; -import { InProgressChapter } from '../_models/in-progress-chapter'; import { PaginatedResult } from '../_models/pagination'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; -import { ReadStatus, SeriesFilter } from '../_models/series-filter'; +import { SeriesFilter } from '../_models/series-filter'; import { SeriesMetadata } from '../_models/series-metadata'; import { Volume } from '../_models/volume'; import { ImageService } from './image.service'; @@ -123,6 +123,15 @@ export class SeriesService { ); } + getRecentlyAddedChapters() { + return this.httpClient.post(this.baseUrl + 'series/recently-added-chapters', {}).pipe( + map(items => { + items.forEach((item, i) => item.id = i); + return items; + }) + ); + } + getOnDeck(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index c096aac5b..611cc7461 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -38,6 +38,7 @@ + {{subtitle}} {{libraryName | sentenceCase}} \ No newline at end of file diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 6788881a3..fba21e1df 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -9,6 +9,7 @@ import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { MangaFormat } from 'src/app/_models/manga-format'; import { PageBookmark } from 'src/app/_models/page-bookmark'; +import { RecentlyAddedItem } from 'src/app/_models/recently-added-item'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; @@ -31,6 +32,10 @@ export class CardItemComponent implements OnInit, OnDestroy { * Name of the card */ @Input() title = ''; + /** + * Shows below the title. Defaults to not visible + */ + @Input() subtitle = ''; /** * Any actions to perform on the card */ @@ -50,7 +55,7 @@ export class CardItemComponent implements OnInit, OnDestroy { /** * This is the entity we are representing. It will be returned if an action is executed. */ - @Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark; + @Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem; /** * If the entity is selected or not. */ @@ -59,6 +64,10 @@ export class CardItemComponent implements OnInit, OnDestroy { * If the entity should show selection code */ @Input() allowSelection: boolean = false; + /** + * This will supress the cannot read archive warning when total pages is 0 + */ + @Input() supressArchiveWarning: boolean = false; /** * Event emitted when item is clicked */ @@ -72,10 +81,6 @@ export class CardItemComponent implements OnInit, OnDestroy { */ libraryName: string | undefined = undefined; libraryId: number | undefined = undefined; - /** - * This will supress the cannot read archive warning when total pages is 0 - */ - supressArchiveWarning: boolean = false; /** * Format of the entity (only applies to Series) */ diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html index 52c642743..00e56db0b 100644 --- a/UI/Web/src/app/library/library.component.html +++ b/UI/Web/src/app/library/library.component.html @@ -11,7 +11,14 @@ - + + + + + + + diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts index 83ff53e80..ca71d562f 100644 --- a/UI/Web/src/app/library/library.component.ts +++ b/UI/Web/src/app/library/library.component.ts @@ -5,8 +5,8 @@ import { Subject } from 'rxjs'; import { take, takeUntil } from 'rxjs/operators'; import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { SeriesRemovedEvent } from '../_models/events/series-removed-event'; -import { InProgressChapter } from '../_models/in-progress-chapter'; import { Library } from '../_models/library'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; @@ -28,8 +28,8 @@ export class LibraryComponent implements OnInit, OnDestroy { isAdmin = false; recentlyAdded: Series[] = []; + recentlyAddedChapters: RecentlyAddedItem[] = []; inProgress: Series[] = []; - continueReading: InProgressChapter[] = []; private readonly onDestroy = new Subject(); @@ -76,6 +76,7 @@ export class LibraryComponent implements OnInit, OnDestroy { reloadSeries() { this.loadRecentlyAdded(); this.loadOnDeck(); + this.loadRecentlyAddedChapters(); } reloadInProgress(series: Series | boolean) { @@ -103,6 +104,16 @@ export class LibraryComponent implements OnInit, OnDestroy { }); } + loadRecentlyAddedChapters() { + this.seriesService.getRecentlyAddedChapters().pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { + this.recentlyAddedChapters = updatedSeries; + }); + } + + handleRecentlyAddedChapterClick(item: RecentlyAddedItem) { + this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); + } + handleSectionClick(sectionTitle: string) { if (sectionTitle.toLowerCase() === 'collections') { this.router.navigate(['collections']);