diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 662e4c462..d64e71cea 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -198,6 +198,7 @@ namespace API.Tests.Parser [InlineData("ch1/cover.png", true)] [InlineData("ch1/backcover.png", false)] [InlineData("backcover.png", false)] + [InlineData("back_cover.png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs new file mode 100644 index 000000000..85842692a --- /dev/null +++ b/API/Controllers/RecommendedController.cs @@ -0,0 +1,86 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Extensions; +using API.Helpers; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class RecommendedController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + + public RecommendedController(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + + /// + /// Quick Reads are series that are less than 2K pages in total. + /// + /// Library to restrict series to + /// + [HttpGet("quick-reads")] + public async Task>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users. + /// + /// Library to restrict series to + /// + [HttpGet("highly-rated")] + public async Task>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetHighlyRated(user.Id, libraryId, userParams); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Chooses a random genre and shows series that are in that without reading progress + /// + /// Library to restrict series to + /// + [HttpGet("more-in")] + public async Task>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetMoreIn(user.Id, libraryId, genreId, userParams); + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + + /// + /// Series that are fully read by the user in no particular order + /// + /// Library to restrict series to + /// + [HttpGet("rediscover")] + public async Task>> GetRediscover(int libraryId, [FromQuery] UserParams userParams) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + + userParams ??= new UserParams(); + var series = await _unitOfWork.SeriesRepository.GetRediscover(user.Id, libraryId, userParams); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + return Ok(series); + } + +} diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index ec44de970..b0090f3c2 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -104,13 +104,17 @@ public interface ISeriesRepository Task GetFullSeriesForSeriesIdAsync(int seriesId); Task GetChunkInfo(int libraryId = 0); Task> GetSeriesMetadataForIdsAsync(IEnumerable seriesIds); - Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); - Task> GetAllLanguagesForLibrariesAsync(List libraryIds); - IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); + Task> GetAllAgeRatingsDtosForLibrariesAsync(List libraryIds); // TODO: Move to LibraryRepository + Task> GetAllLanguagesForLibrariesAsync(List libraryIds); // TODO: Move to LibraryRepository + IEnumerable GetAllPublicationStatusesDtosForLibrariesAsync(List libraryIds); // TODO: Move to LibraryRepository Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30); Task GetRelatedSeries(int userId, int seriesId); Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind); + Task> GetQuickReads(int userId, int libraryId, UserParams userParams); + Task> GetHighlyRated(int userId, int libraryId, UserParams userParams); + Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams); + Task> GetRediscover(int userId, int libraryId, UserParams userParams); } public class SeriesRepository : ISeriesRepository @@ -416,7 +420,9 @@ public class SeriesRepository : ISeriesRepository .Include(s => s.Metadata) .ThenInclude(m => m.Genres) .Include(s => s.Metadata) - .ThenInclude(m => m.People); + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Tags); } return await query.SingleOrDefaultAsync(); @@ -972,9 +978,7 @@ public class SeriesRepository : ISeriesRepository public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) { - var libraryIds = _context.AppUser - .Where(u => u.Id == userId) - .SelectMany(l => l.Libraries.Select(lib => lib.Id)); + var libraryIds = GetLibraryIdsForUser(userId); var usersSeriesIds = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Select(s => s.Id); @@ -995,14 +999,100 @@ public class SeriesRepository : ISeriesRepository .ToListAsync(); } - public async Task GetRelatedSeries(int userId, int seriesId) + private IQueryable GetLibraryIdsForUser(int userId) + { + return _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Select(lib => lib.Id)); + } + + public async Task> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams) { var libraryIds = _context.AppUser .Where(u => u.Id == userId) - .SelectMany(l => l.Libraries.Select(lib => lib.Id)); - var usersSeriesIds = _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) - .Select(s => s.Id); + .SelectMany(l => l.Libraries.Where(l => l.Id == libraryId).Select(lib => lib.Id)); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + + var query = _context.Series + .Where(s => s.Metadata.Genres.Select(g => g.Id).Contains(genreId)) + .Where(s => usersSeriesIds.Contains(s.Id)) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider); + + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task> GetRediscover(int userId, int libraryId, UserParams userParams) + { + var libraryIds = _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Where(l => l.Id == libraryId).Select(lib => lib.Id)); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var distinctSeriesIdsWithProgress = _context.AppUserProgresses + .Where(s => usersSeriesIds.Contains(s.SeriesId)) + .Select(p => p.SeriesId) + .Distinct(); + + var query = _context.Series + .Where(s => distinctSeriesIdsWithProgress.Contains(s.Id) && + _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId) + .Sum(s1 => s1.PagesRead) >= s.Pages) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + + public async Task> GetHighlyRated(int userId, int libraryId, UserParams userParams) + { + var libraryIds = _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Where(l => l.Id == libraryId).Select(lib => lib.Id)); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var distinctSeriesIdsWithHighRating = _context.AppUserRating + .Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4) + .Select(p => p.SeriesId) + .Distinct(); + + var query = _context.Series + .Where(s => distinctSeriesIdsWithHighRating.Contains(s.Id)) + .AsSplitQuery() + .OrderByDescending(s => _context.AppUserRating.Where(r => r.SeriesId == s.Id).Select(r => r.Rating).Average()) + .ProjectTo(_mapper.ConfigurationProvider); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + + public async Task> GetQuickReads(int userId, int libraryId, UserParams userParams) + { + var libraryIds = _context.AppUser + .Where(u => u.Id == userId) + .SelectMany(l => l.Libraries.Where(l => l.Id == libraryId).Select(lib => lib.Id)); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var distinctSeriesIdsWithProgress = _context.AppUserProgresses + .Where(s => usersSeriesIds.Contains(s.SeriesId)) + .Select(p => p.SeriesId) + .Distinct(); + + + var query = _context.Series + .Where(s => s.Pages < 2000 && !distinctSeriesIdsWithProgress.Contains(s.Id) && + usersSeriesIds.Contains(s.Id)) + .Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing) + .AsSplitQuery() + .ProjectTo(_mapper.ConfigurationProvider); + + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task GetRelatedSeries(int userId, int seriesId) + { + var libraryIds = GetLibraryIdsForUser(userId); + var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); return new RelatedSeriesDto() { @@ -1032,6 +1122,13 @@ public class SeriesRepository : ISeriesRepository }; } + private IQueryable GetSeriesIdsForLibraryIds(IQueryable libraryIds) + { + return _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .Select(s => s.Id); + } + private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind) { return await _context.Series.SelectMany(s => diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 8d2dcf817..57fe51526 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -54,7 +54,7 @@ namespace API.Parser MatchOptions, RegexTimeout); private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? tags.SingleOrDefault(t => t.Id == existing.Id) == null)) { - if (tags.SingleOrDefault(t => t.Id == existing.Id) == null) - { - // Remove tag - series.Metadata.Tags.Remove(existing); - isModified = true; - } + // Remove tag + series.Metadata.Tags.Remove(existing); + isModified = true; } // At this point, all tags that aren't in dto have been removed. diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 9d9305ced..6238c7b6a 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -7,7 +7,7 @@ import { Preferences } from '../_models/preferences/preferences'; import { User } from '../_models/user'; import { Router } from '@angular/router'; import { MessageHubService } from './message-hub.service'; -import { ThemeService } from '../theme.service'; +import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/invite-user-response'; @Injectable({ diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 0577bd663..a6ab89927 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -1,12 +1,10 @@ -import { DOCUMENT } from '@angular/common'; -import { Inject, Injectable, OnDestroy } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; -import { ThemeService } from '../theme.service'; +import { ThemeService } from './theme.service'; import { RecentlyAddedItem } from '../_models/recently-added-item'; import { AccountService } from './account.service'; -import { NavService } from './nav.service'; @Injectable({ providedIn: 'root' diff --git a/UI/Web/src/app/_services/recommendation.service.ts b/UI/Web/src/app/_services/recommendation.service.ts new file mode 100644 index 000000000..ae3360ec3 --- /dev/null +++ b/UI/Web/src/app/_services/recommendation.service.ts @@ -0,0 +1,45 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { map } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { UtilityService } from '../shared/_services/utility.service'; +import { PaginatedResult } from '../_models/pagination'; +import { Series } from '../_models/series'; + +@Injectable({ + providedIn: 'root' +}) +export class RecommendationService { + + private baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } + + getQuickReads(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/quick-reads?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + + getHighlyRated(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/highly-rated?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + + getRediscover(libraryId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/rediscover?libraryId=' + libraryId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } + + getMoreIn(libraryId: number, genreId: number, pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + return this.httpClient.get>(this.baseUrl + 'recommended/more-in?libraryId=' + libraryId + '&genreId=' + genreId, {observe: 'response', params}) + .pipe(map(response => this.utilityService.createPaginatedResult(response))); + } +} diff --git a/UI/Web/src/app/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts similarity index 100% rename from UI/Web/src/app/scroll.service.ts rename to UI/Web/src/app/_services/scroll.service.ts diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 0c340a802..6250a4f99 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -3,14 +3,13 @@ import { Injectable } from '@angular/core'; import { of } from 'rxjs'; import { map } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { UtilityService } from '../shared/_services/utility.service'; import { Chapter } from '../_models/chapter'; import { ChapterMetadata } from '../_models/chapter-metadata'; import { CollectionTag } from '../_models/collection-tag'; import { PaginatedResult } from '../_models/pagination'; -import { RecentlyAddedItem } from '../_models/recently-added-item'; import { Series } from '../_models/series'; import { RelatedSeries } from '../_models/series-detail/related-series'; -import { RelationKind } from '../_models/series-detail/relation-kind'; import { SeriesDetail } from '../_models/series-detail/series-detail'; import { SeriesFilter } from '../_models/series-filter'; import { SeriesGroup } from '../_models/series-group'; @@ -27,43 +26,28 @@ export class SeriesService { paginatedResults: PaginatedResult = new PaginatedResult(); paginatedSeriesForTagsResults: PaginatedResult = new PaginatedResult(); - constructor(private httpClient: HttpClient, private imageService: ImageService) { } - - _cachePaginatedResults(response: any, paginatedVariable: PaginatedResult) { - if (response.body === null) { - paginatedVariable.result = []; - } else { - paginatedVariable.result = response.body; - } - - const pageHeader = response.headers.get('Pagination'); - if (pageHeader !== null) { - paginatedVariable.pagination = JSON.parse(pageHeader); - } - - return paginatedVariable; - } + constructor(private httpClient: HttpClient, private imageService: ImageService, private utilityService: UtilityService) { } getAllSeries(pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = this.createSeriesFilter(filter); return this.httpClient.post>(this.baseUrl + 'series/all', data, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedResults); + return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) ); } getSeriesForLibrary(libraryId: number, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); const data = this.createSeriesFilter(filter); return this.httpClient.post>(this.baseUrl + 'series?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedResults); + return this.utilityService.createPaginatedResult(response, this.paginatedResults); }) ); } @@ -123,11 +107,11 @@ export class SeriesService { getRecentlyAdded(libraryId: number = 0, pageNum?: number, itemsPerPage?: number, filter?: SeriesFilter) { const data = this.createSeriesFilter(filter); let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.post(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map(response => { - return this._cachePaginatedResults(response, new PaginatedResult()); + return this.utilityService.createPaginatedResult(response, new PaginatedResult()); }) ); } @@ -140,11 +124,11 @@ export class SeriesService { const data = this.createSeriesFilter(filter); let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.post(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe( map(response => { - return this._cachePaginatedResults(response, new PaginatedResult()); + return this.utilityService.createPaginatedResult(response, new PaginatedResult()); })); } @@ -175,11 +159,11 @@ export class SeriesService { getSeriesForTag(collectionTagId: number, pageNum?: number, itemsPerPage?: number) { let params = new HttpParams(); - params = this._addPaginationIfExists(params, pageNum, itemsPerPage); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); return this.httpClient.get>(this.baseUrl + 'series/series-by-collection?collectionId=' + collectionTagId, {observe: 'response', params}).pipe( map((response: any) => { - return this._cachePaginatedResults(response, this.paginatedSeriesForTagsResults); + return this.utilityService.createPaginatedResult(response, this.paginatedSeriesForTagsResults); }) ); } @@ -201,13 +185,7 @@ export class SeriesService { return this.httpClient.get(this.baseUrl + 'series/series-detail?seriesId=' + seriesId); } - _addPaginationIfExists(params: HttpParams, pageNum?: number, itemsPerPage?: number) { - if (pageNum !== null && pageNum !== undefined && itemsPerPage !== null && itemsPerPage !== undefined) { - params = params.append('pageNumber', pageNum + ''); - params = params.append('pageSize', itemsPerPage + ''); - } - return params; - } + createSeriesFilter(filter?: SeriesFilter) { if (filter !== undefined) return filter; diff --git a/UI/Web/src/app/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts similarity index 92% rename from UI/Web/src/app/theme.service.ts rename to UI/Web/src/app/_services/theme.service.ts index bf6c33ba0..8165cc235 100644 --- a/UI/Web/src/app/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -4,11 +4,10 @@ import { Inject, Injectable, OnDestroy, Renderer2, RendererFactory2, SecurityCon import { DomSanitizer } from '@angular/platform-browser'; import { map, ReplaySubject, Subject, takeUntil } from 'rxjs'; import { environment } from 'src/environments/environment'; -import { ConfirmService } from './shared/confirm.service'; -import { NotificationProgressEvent } from './_models/events/notification-progress-event'; -import { SiteThemeProgressEvent } from './_models/events/site-theme-progress-event'; -import { SiteTheme, ThemeProvider } from './_models/preferences/site-theme'; -import { EVENTS, MessageHubService } from './_services/message-hub.service'; +import { ConfirmService } from '../shared/confirm.service'; +import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; +import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme'; +import { EVENTS, MessageHubService } from './message-hub.service'; diff --git a/UI/Web/src/app/all-series/all-series-routing.module.ts b/UI/Web/src/app/all-series/all-series-routing.module.ts new file mode 100644 index 000000000..5e9969b4d --- /dev/null +++ b/UI/Web/src/app/all-series/all-series-routing.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from "@angular/core"; +import { Routes, RouterModule } from "@angular/router"; +import { AuthGuard } from "../_guards/auth.guard"; +import { AllSeriesComponent } from "./all-series.component"; +const routes: Routes = [ + {path: '**', component: AllSeriesComponent, pathMatch: 'full', canActivate: [AuthGuard]}, + { + path: '', + component: AllSeriesComponent, + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + } +]; + + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class AllSeriesRoutingModule { } \ No newline at end of file diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts index 7298ce71b..70c919ef3 100644 --- a/UI/Web/src/app/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -7,11 +7,10 @@ import { BulkSelectionService } from '../cards/bulk-selection.service'; import { FilterSettings } from '../metadata-filter/filter-settings'; import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service'; import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; -import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; import { FilterEvent, SeriesFilter } from '../_models/series-filter'; -import { ActionItem, Action } from '../_services/action-factory.service'; +import { Action } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; diff --git a/UI/Web/src/app/all-series/all-series.module.ts b/UI/Web/src/app/all-series/all-series.module.ts new file mode 100644 index 000000000..1f6503d9b --- /dev/null +++ b/UI/Web/src/app/all-series/all-series.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AllSeriesComponent } from './all-series.component'; +import { AllSeriesRoutingModule } from './all-series-routing.module'; +import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module'; + + + +@NgModule({ + declarations: [ + AllSeriesComponent + ], + imports: [ + CommonModule, + AllSeriesRoutingModule, + + SharedSideNavCardsModule + + ] +}) +export class AllSeriesModule { } diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index b0ca5be2e..381c183ca 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -1,19 +1,12 @@ import { NgModule } from '@angular/core'; -import { Routes, RouterModule } from '@angular/router'; -import { LibraryDetailComponent } from './library-detail/library-detail.component'; -import { SeriesDetailComponent } from './series-detail/series-detail.component'; -import { UserLoginComponent } from './user-login/user-login.component'; +import { Routes, RouterModule, PreloadAllModules } from '@angular/router'; import { AuthGuard } from './_guards/auth.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard'; -import { DashboardComponent } from './dashboard/dashboard.component'; -import { AllSeriesComponent } from './all-series/all-series.component'; import { AdminGuard } from './_guards/admin.guard'; -import { ThemeTestComponent } from './theme-test/theme-test.component'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules - +// TODO: Use Prefetching of LazyLoaded Modules const routes: Routes = [ - {path: '', component: UserLoginComponent}, { path: 'admin', canActivate: [AdminGuard], @@ -47,40 +40,48 @@ const routes: Routes = [ loadChildren: () => import('../app/bookmark/bookmark.module').then(m => m.BookmarkModule) }, { - path: '', + path: 'all-series', + loadChildren: () => import('../app/all-series/all-series.module').then(m => m.AllSeriesModule) + }, + { + path: 'libraries', + loadChildren: () => import('../app/dashboard/dashboard.module').then(m => m.DashboardModule) + }, + { + path: 'library', runGuardsAndResolvers: 'always', canActivate: [AuthGuard, LibraryAccessGuard], children: [ - {path: 'library/:id', component: LibraryDetailComponent}, - {path: 'library/:libraryId/series/:seriesId', component: SeriesDetailComponent}, { - path: 'library/:libraryId/series/:seriesId/manga', + path: ':libraryId', + pathMatch: 'full', + loadChildren: () => import('../app/library-detail/library-detail.module').then(m => m.LibraryDetailModule) + }, + { + path: ':libraryId/series/:seriesId', + pathMatch: 'full', + loadChildren: () => import('../app/series-detail/series-detail.module').then(m => m.SeriesDetailModule) + }, + { + path: ':libraryId/series/:seriesId/manga', loadChildren: () => import('../app/manga-reader/manga-reader.module').then(m => m.MangaReaderModule) }, { - path: 'library/:libraryId/series/:seriesId/book', + path: ':libraryId/series/:seriesId/book', loadChildren: () => import('../app/book-reader/book-reader.module').then(m => m.BookReaderModule) - } + }, ] }, { - path: '', - runGuardsAndResolvers: 'always', - canActivate: [AuthGuard], - children: [ - {path: 'library', component: DashboardComponent}, - {path: 'all-series', component: AllSeriesComponent}, // TODO: This might be better as a separate module - - ] + path: 'theme', + loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule) }, - {path: 'theme', component: ThemeTestComponent}, - - {path: 'login', component: UserLoginComponent}, // TODO: move this to registration module - {path: '**', component: UserLoginComponent, pathMatch: 'full'} + {path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)}, + {path: '**', loadChildren: () => import('../app/dashboard/dashboard.module').then(m => m.DashboardModule), pathMatch: 'full'}, ]; @NgModule({ - imports: [RouterModule.forRoot(routes, {scrollPositionRestoration: 'enabled'})], + imports: [RouterModule.forRoot(routes, {scrollPositionRestoration: 'enabled', preloadingStrategy: PreloadAllModules})], exports: [RouterModule] }) export class AppRoutingModule { } diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 6d6d67ae4..a130e85e1 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -4,85 +4,30 @@ import { NgModule } from '@angular/core'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; -import { - NgbAccordionModule, NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; -import { NavHeaderComponent } from './nav-header/nav-header.component'; import { JwtInterceptor } from './_interceptors/jwt.interceptor'; -import { UserLoginComponent } from './user-login/user-login.component'; import { ToastrModule } from 'ngx-toastr'; import { ErrorInterceptor } from './_interceptors/error.interceptor'; -import { LibraryComponent } from './library/library.component'; -import { SharedModule } from './shared/shared.module'; -import { LibraryDetailComponent } from './library-detail/library-detail.component'; -import { SeriesDetailComponent } from './series-detail/series-detail.component'; -import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; -import { CarouselModule } from './carousel/carousel.module'; -import { TypeaheadModule } from './typeahead/typeahead.module'; -import { DashboardComponent } from './dashboard/dashboard.component'; -import { CardsModule } from './cards/cards.module'; -import { CollectionsModule } from './collections/collections.module'; -import { ReadingListModule } from './reading-list/reading-list.module'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; -import { EventsWidgetComponent } from './events-widget/events-widget.component'; -import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; -import { AllSeriesComponent } from './all-series/all-series.component'; -import { RegistrationModule } from './registration/registration.module'; -import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component'; -import { ThemeTestComponent } from './theme-test/theme-test.component'; -import { PipeModule } from './pipe/pipe.module'; import { SidenavModule } from './sidenav/sidenav.module'; +import { NavModule } from './nav/nav.module'; + @NgModule({ declarations: [ AppComponent, - NavHeaderComponent, - UserLoginComponent, - LibraryComponent, - LibraryDetailComponent, - SeriesDetailComponent, - ReviewSeriesModalComponent, - DashboardComponent, - EventsWidgetComponent, - SeriesMetadataDetailComponent, - AllSeriesComponent, - GroupedTypeaheadComponent, - ThemeTestComponent, + ], imports: [ HttpClientModule, BrowserModule, AppRoutingModule, BrowserAnimationsModule, - ReactiveFormsModule, - FormsModule, // EditCollection Modal - - NgbDropdownModule, // Nav - NgbPopoverModule, // Nav Events toggle - NgbNavModule, - - NgbRatingModule, // Series Detail & Filter - NgbPaginationModule, - - NgbCollapseModule, // Login - - SharedModule, - CarouselModule, - TypeaheadModule, - CardsModule, - CollectionsModule, - ReadingListModule, - RegistrationModule, - - NgbAccordionModule, // ThemeTest Component only - PipeModule, - - PipeModule, - SidenavModule, // For sidenav + SidenavModule, + NavModule, ToastrModule.forRoot({ positionClass: 'toast-bottom-right', diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index e8657e2aa..bf501d3b2 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -20,11 +20,11 @@ import { animate, state, style, transition, trigger } from '@angular/animations' import { Stack } from 'src/app/shared/data-structures/stack'; import { MemberService } from 'src/app/_services/member.service'; import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; -import { ScrollService } from 'src/app/scroll.service'; import { MangaFormat } from 'src/app/_models/manga-format'; import { LibraryService } from 'src/app/_services/library.service'; import { LibraryType } from 'src/app/_models/library'; -import { ThemeService } from 'src/app/theme.service'; +import { ThemeService } from 'src/app/_services/theme.service'; +import { ScrollService } from 'src/app/_services/scroll.service'; interface PageStyle { @@ -415,7 +415,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const chapterId = this.route.snapshot.paramMap.get('chapterId'); if (libraryId === null || seriesId === null || chapterId === null) { - this.router.navigateByUrl('/library'); + this.router.navigateByUrl('/libraries'); return; } diff --git a/UI/Web/src/app/bookmark/bookmark.module.ts b/UI/Web/src/app/bookmark/bookmark.module.ts index 12d5c7f46..40d7716e1 100644 --- a/UI/Web/src/app/bookmark/bookmark.module.ts +++ b/UI/Web/src/app/bookmark/bookmark.module.ts @@ -1,11 +1,8 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { CardsModule } from '../cards/cards.module'; -import { SharedModule } from '../shared/shared.module'; -import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; -import { SidenavModule } from '../sidenav/sidenav.module'; import { BookmarkRoutingModule } from './bookmark-routing.module'; import { BookmarksComponent } from './bookmarks/bookmarks.component'; +import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module'; @@ -15,10 +12,8 @@ import { BookmarksComponent } from './bookmarks/bookmarks.component'; ], imports: [ CommonModule, - CardsModule, - SharedModule, - SidenavModule, - NgbTooltipModule, + + SharedSideNavCardsModule, BookmarkRoutingModule ] diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index 3095fa415..f18f3a967 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -54,7 +54,6 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series- MetadataFilterModule, - NgbNavModule, NgbTooltipModule, // Card item NgbCollapseModule, NgbRatingModule, diff --git a/UI/Web/src/app/collections/collections.module.ts b/UI/Web/src/app/collections/collections.module.ts index fb21685e4..54ef17d32 100644 --- a/UI/Web/src/app/collections/collections.module.ts +++ b/UI/Web/src/app/collections/collections.module.ts @@ -2,10 +2,9 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { CollectionDetailComponent } from './collection-detail/collection-detail.component'; import { SharedModule } from '../shared/shared.module'; -import { CollectionsRoutingModule } from './collections-routing.module'; -import { CardsModule } from '../cards/cards.module'; import { AllCollectionsComponent } from './all-collections/all-collections.component'; -import { SidenavModule } from '../sidenav/sidenav.module'; +import { CollectionsRoutingModule } from './collections-routing.module'; +import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module'; @@ -17,9 +16,10 @@ import { SidenavModule } from '../sidenav/sidenav.module'; imports: [ CommonModule, SharedModule, - CardsModule, + + SharedSideNavCardsModule, + CollectionsRoutingModule, - SidenavModule ], exports: [ AllCollectionsComponent diff --git a/UI/Web/src/app/dashboard/dashboard-routing.module.ts b/UI/Web/src/app/dashboard/dashboard-routing.module.ts new file mode 100644 index 000000000..adf41b73b --- /dev/null +++ b/UI/Web/src/app/dashboard/dashboard-routing.module.ts @@ -0,0 +1,21 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { AuthGuard } from '../_guards/auth.guard'; +import { DashboardComponent } from './dashboard.component'; + + +const routes: Routes = [ + { + path: '', + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + component: DashboardComponent, + } +]; + + +@NgModule({ + imports: [RouterModule.forChild(routes), ], + exports: [RouterModule] +}) +export class DashboardRoutingModule { } diff --git a/UI/Web/src/app/dashboard/dashboard.component.html b/UI/Web/src/app/dashboard/dashboard.component.html index f4c84f091..e34bf4adf 100644 --- a/UI/Web/src/app/dashboard/dashboard.component.html +++ b/UI/Web/src/app/dashboard/dashboard.component.html @@ -1,3 +1,31 @@ - \ No newline at end of file + +
+
+

There are no libraries setup yet. Configure some in Server settings.

+
+
+

You haven't been granted access to any libraries.

+
+
+
+ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/UI/Web/src/app/dashboard/dashboard.component.ts b/UI/Web/src/app/dashboard/dashboard.component.ts index c25c86d9a..b772de46d 100644 --- a/UI/Web/src/app/dashboard/dashboard.component.ts +++ b/UI/Web/src/app/dashboard/dashboard.component.ts @@ -1,20 +1,180 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; -import { ActivatedRoute } from '@angular/router'; +import { Router } from '@angular/router'; +import { ReplaySubject, Subject } from 'rxjs'; +import { debounceTime, take, takeUntil } from 'rxjs/operators'; +import { FilterQueryParam } from '../shared/_services/filter-utilities.service'; +import { SeriesAddedEvent } from '../_models/events/series-added-event'; +import { SeriesRemovedEvent } from '../_models/events/series-removed-event'; +import { Library } from '../_models/library'; +import { RecentlyAddedItem } from '../_models/recently-added-item'; +import { Series } from '../_models/series'; +import { SortField } from '../_models/series-filter'; +import { SeriesGroup } from '../_models/series-group'; +import { User } from '../_models/user'; +import { AccountService } from '../_services/account.service'; +import { ImageService } from '../_services/image.service'; +import { LibraryService } from '../_services/library.service'; +import { MessageHubService, EVENTS } from '../_services/message-hub.service'; +import { SeriesService } from '../_services/series.service'; @Component({ selector: 'app-dashboard', templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'] }) -export class DashboardComponent implements OnInit { +export class DashboardComponent implements OnInit, OnDestroy { + /** + * By default, 0, but if non-zero, will limit all API calls to library id + */ + @Input() libraryId: number = 0; - constructor(public route: ActivatedRoute, private titleService: Title) { - this.titleService.setTitle('Kavita - Dashboard'); + user: User | undefined; + libraries: Library[] = []; + isLoading = false; + isAdmin = false; + + recentlyUpdatedSeries: SeriesGroup[] = []; + inProgress: Series[] = []; + recentlyAddedSeries: Series[] = []; + + private readonly onDestroy = new Subject(); + + /** + * We use this Replay subject to slow the amount of times we reload the UI + */ + private loadRecentlyAdded$: ReplaySubject = new ReplaySubject(); + + constructor(public accountService: AccountService, private libraryService: LibraryService, + private seriesService: SeriesService, private router: Router, + private titleService: Title, public imageService: ImageService, + private messageHub: MessageHubService) { + this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => { + if (res.event === EVENTS.SeriesAdded) { + const seriesAddedEvent = res.payload as SeriesAddedEvent; + + this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { + this.recentlyAddedSeries.unshift(series); + }); + } else if (res.event === EVENTS.SeriesRemoved) { + const seriesRemovedEvent = res.payload as SeriesRemovedEvent; + + this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); + this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId); + this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); + } else if (res.event === EVENTS.ScanSeries) { + // We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time. + this.loadRecentlyAdded$.next(); + } + }); + + this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntil(this.onDestroy)).subscribe(() => this.loadRecentlyAdded()); } ngOnInit(): void { + this.titleService.setTitle('Kavita - Dashboard'); + this.isLoading = true; + this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + this.user = user; + if (this.user) { + this.isAdmin = this.accountService.hasAdminRole(this.user); + this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe(libraries => { + this.libraries = libraries; + this.isLoading = false; + }); + } + }); + + this.reloadSeries(); } + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + + reloadSeries() { + this.loadOnDeck(); + this.loadRecentlyAdded(); + this.loadRecentlyAddedSeries(); + } + + reloadInProgress(series: Series | boolean) { + if (series === true || series === false) { + if (!series) {return;} + } + // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request + const seriesObj = (series as Series); + if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) { + return; + } + + this.loadOnDeck(); + } + + loadOnDeck() { + let api = this.seriesService.getOnDeck(); + if (this.libraryId > 0) { + api = this.seriesService.getOnDeck(this.libraryId); + } + api.pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.inProgress = updatedSeries.result; + }); + } + + loadRecentlyAddedSeries() { + let api = this.seriesService.getRecentlyAdded(); + if (this.libraryId > 0) { + api = this.seriesService.getRecentlyAdded(this.libraryId); + } + api.pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { + this.recentlyAddedSeries = updatedSeries.result; + }); + } + + + loadRecentlyAdded() { + let api = this.seriesService.getRecentlyUpdatedSeries(); + if (this.libraryId > 0) { + api = this.seriesService.getRecentlyUpdatedSeries(); + } + api.pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { + this.recentlyUpdatedSeries = updatedSeries.filter(group => { + if (this.libraryId === 0) return true; + return group.libraryId === this.libraryId; + }); + }); + } + + handleRecentlyAddedChapterClick(item: RecentlyAddedItem) { + this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); + } + + handleSectionClick(sectionTitle: string) { + if (sectionTitle.toLowerCase() === 'recently updated series') { + const params: any = {}; + params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc + params[FilterQueryParam.Page] = 1; + this.router.navigate(['all-series'], {queryParams: params}); + } else if (sectionTitle.toLowerCase() === 'on deck') { + const params: any = {}; + params[FilterQueryParam.ReadStatus] = 'true,false,false'; + params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc + params[FilterQueryParam.Page] = 1; + this.router.navigate(['all-series'], {queryParams: params}); + }else if (sectionTitle.toLowerCase() === 'newly added series') { + const params: any = {}; + params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc + params[FilterQueryParam.Page] = 1; + this.router.navigate(['all-series'], {queryParams: params}); + } + } + + removeFromArray(arr: Array, element: any) { + const index = arr.indexOf(element); + if (index >= 0) { + arr.splice(index); + } + } } diff --git a/UI/Web/src/app/dashboard/dashboard.module.ts b/UI/Web/src/app/dashboard/dashboard.module.ts new file mode 100644 index 000000000..55b9a5329 --- /dev/null +++ b/UI/Web/src/app/dashboard/dashboard.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { DashboardRoutingModule } from './dashboard-routing.module'; + +import { CarouselModule } from '../carousel/carousel.module'; +import { DashboardComponent } from './dashboard.component'; +import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module'; + + + +@NgModule({ + declarations: [DashboardComponent], + imports: [ + CommonModule, + + CarouselModule, + + SharedSideNavCardsModule, + + DashboardRoutingModule + ] +}) +export class DashboardModule { } diff --git a/UI/Web/src/app/dev-only/dev-only-routing.module.ts b/UI/Web/src/app/dev-only/dev-only-routing.module.ts new file mode 100644 index 000000000..759989c57 --- /dev/null +++ b/UI/Web/src/app/dev-only/dev-only-routing.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { ThemeTestComponent } from './theme-test/theme-test.component'; + + +const routes: Routes = [ + { + path: '', + component: ThemeTestComponent, + } +]; + + +@NgModule({ + imports: [RouterModule.forChild(routes), ], + exports: [RouterModule] +}) +export class DevOnlyRoutingModule { } diff --git a/UI/Web/src/app/dev-only/dev-only.module.ts b/UI/Web/src/app/dev-only/dev-only.module.ts new file mode 100644 index 000000000..4ecdc6133 --- /dev/null +++ b/UI/Web/src/app/dev-only/dev-only.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { CardsModule } from '../cards/cards.module'; +import { TypeaheadModule } from '../typeahead/typeahead.module'; +import { ThemeTestComponent } from './theme-test/theme-test.component'; +import { SharedModule } from '../shared/shared.module'; +import { PipeModule } from '../pipe/pipe.module'; +import { DevOnlyRoutingModule } from './dev-only-routing.module'; +import { FormsModule } from '@angular/forms'; + +/** + * This module contains components that aren't meant to ship with main code. They are there to test things out. This module may be deleted in future updates. + */ + +@NgModule({ + declarations: [ + ThemeTestComponent + ], + imports: [ + CommonModule, + FormsModule, + + + TypeaheadModule, + CardsModule, + NgbAccordionModule, + NgbNavModule, + + + SharedModule, + PipeModule, + + DevOnlyRoutingModule + ] +}) +export class DevOnlyModule { } diff --git a/UI/Web/src/app/theme-test/theme-test.component.html b/UI/Web/src/app/dev-only/theme-test/theme-test.component.html similarity index 100% rename from UI/Web/src/app/theme-test/theme-test.component.html rename to UI/Web/src/app/dev-only/theme-test/theme-test.component.html diff --git a/UI/Web/src/app/theme-test/theme-test.component.scss b/UI/Web/src/app/dev-only/theme-test/theme-test.component.scss similarity index 100% rename from UI/Web/src/app/theme-test/theme-test.component.scss rename to UI/Web/src/app/dev-only/theme-test/theme-test.component.scss diff --git a/UI/Web/src/app/theme-test/theme-test.component.ts b/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts similarity index 83% rename from UI/Web/src/app/theme-test/theme-test.component.ts rename to UI/Web/src/app/dev-only/theme-test/theme-test.component.ts index bcb26dafd..7bd6635f8 100644 --- a/UI/Web/src/app/theme-test/theme-test.component.ts +++ b/UI/Web/src/app/dev-only/theme-test/theme-test.component.ts @@ -1,11 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; -import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component'; -import { ThemeService } from '../theme.service'; -import { MangaFormat } from '../_models/manga-format'; -import { Person, PersonRole } from '../_models/person'; -import { Series } from '../_models/series'; -import { NavService } from '../_services/nav.service'; +import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component'; +import { ThemeService } from '../../_services/theme.service'; +import { MangaFormat } from '../../_models/manga-format'; +import { Person, PersonRole } from '../../_models/person'; +import { Series } from '../../_models/series'; +import { NavService } from '../../_services/nav.service'; @Component({ selector: 'app-theme-test', diff --git a/UI/Web/src/app/library-detail/library-detail-routing.module.ts b/UI/Web/src/app/library-detail/library-detail-routing.module.ts new file mode 100644 index 000000000..0ac5d2de2 --- /dev/null +++ b/UI/Web/src/app/library-detail/library-detail-routing.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { AuthGuard } from '../_guards/auth.guard'; +import { LibraryAccessGuard } from '../_guards/library-access.guard'; +import { LibraryDetailComponent } from './library-detail.component'; + + +const routes: Routes = [ + { + path: ':libraryId', + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard, LibraryAccessGuard], + component: LibraryDetailComponent + }, + { + path: '', + component: LibraryDetailComponent + } +]; + + +@NgModule({ + imports: [RouterModule.forChild(routes), ], + exports: [RouterModule] +}) +export class LibraryDetailRoutingModule { } diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index a3c9d2ccc..2b3019d18 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -10,7 +10,7 @@ {{tab.title | sentenceCase}} - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.scss b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.scss similarity index 100% rename from UI/Web/src/app/_modals/review-series-modal/review-series-modal.component.scss rename to UI/Web/src/app/library-detail/library-recommended/library-recommended.component.scss diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts new file mode 100644 index 000000000..56e95a428 --- /dev/null +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts @@ -0,0 +1,65 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { map, Observable, shareReplay } from 'rxjs'; +import { Genre } from 'src/app/_models/genre'; +import { Series } from 'src/app/_models/series'; +import { MetadataService } from 'src/app/_services/metadata.service'; +import { RecommendationService } from 'src/app/_services/recommendation.service'; +import { SeriesService } from 'src/app/_services/series.service'; + +@Component({ + selector: 'app-library-recommended', + templateUrl: './library-recommended.component.html', + styleUrls: ['./library-recommended.component.scss'] +}) +export class LibraryRecommendedComponent implements OnInit { + + @Input() libraryId: number = 0; + + quickReads$!: Observable; + highlyRated$!: Observable; + onDeck$!: Observable; + rediscover$!: Observable; + + moreIn$!: Observable; + genre: string = ''; + genre$!: Observable; + + + constructor(private recommendationService: RecommendationService, private seriesService: SeriesService, private metadataService: MetadataService) { } + + ngOnInit(): void { + + this.quickReads$ = this.recommendationService.getQuickReads(this.libraryId) + .pipe(map(p => p.result), shareReplay()); + + this.highlyRated$ = this.recommendationService.getHighlyRated(this.libraryId) + .pipe(map(p => p.result), shareReplay()); + + this.rediscover$ = this.recommendationService.getRediscover(this.libraryId) + .pipe(map(p => p.result), shareReplay()); + + this.onDeck$ = this.seriesService.getOnDeck(this.libraryId) + .pipe(map(p => p.result), shareReplay()); + + this.genre$ = this.metadataService.getAllGenres([this.libraryId]).pipe(map(genres => genres[Math.floor(Math.random() * genres.length)]), shareReplay()); + this.genre$.subscribe(genre => { + this.moreIn$ = this.recommendationService.getMoreIn(this.libraryId, genre.id).pipe(map(p => p.result), shareReplay()); + }); + + } + + + reloadInProgress(series: Series | boolean) { + if (series === true || series === false) { + if (!series) {return;} + } + // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request + const seriesObj = (series as Series); + if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) { + return; + } + + //this.loadOnDeck(); + } + +} diff --git a/UI/Web/src/app/library/library.component.html b/UI/Web/src/app/library/library.component.html deleted file mode 100644 index c734b13f9..000000000 --- a/UI/Web/src/app/library/library.component.html +++ /dev/null @@ -1,29 +0,0 @@ - -
-
-

There are no libraries setup yet. Configure some in Server settings.

-
-
-

You haven't been granted access to any libraries.

-
-
-
- - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/UI/Web/src/app/library/library.component.ts b/UI/Web/src/app/library/library.component.ts deleted file mode 100644 index a1ec41864..000000000 --- a/UI/Web/src/app/library/library.component.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { Title } from '@angular/platform-browser'; -import { Router } from '@angular/router'; -import { ReplaySubject, Subject } from 'rxjs'; -import { debounceTime, filter, take, takeUntil } from 'rxjs/operators'; -import { FilterQueryParam } from '../shared/_services/filter-utilities.service'; -import { SeriesAddedEvent } from '../_models/events/series-added-event'; -import { SeriesRemovedEvent } from '../_models/events/series-removed-event'; -import { Library } from '../_models/library'; -import { RecentlyAddedItem } from '../_models/recently-added-item'; -import { Series } from '../_models/series'; -import { SortField } from '../_models/series-filter'; -import { SeriesGroup } from '../_models/series-group'; -import { User } from '../_models/user'; -import { AccountService } from '../_services/account.service'; -import { ImageService } from '../_services/image.service'; -import { LibraryService } from '../_services/library.service'; -import { EVENTS, MessageHubService } from '../_services/message-hub.service'; -import { SeriesService } from '../_services/series.service'; - -@Component({ - selector: 'app-library', - templateUrl: './library.component.html', - styleUrls: ['./library.component.scss'] -}) -export class LibraryComponent implements OnInit, OnDestroy { - - /** - * By default, 0, but if non-zero, will limit all API calls to library id - */ - @Input() libraryId: number = 0; - - user: User | undefined; - libraries: Library[] = []; - isLoading = false; - isAdmin = false; - - recentlyUpdatedSeries: SeriesGroup[] = []; - inProgress: Series[] = []; - recentlyAddedSeries: Series[] = []; - - private readonly onDestroy = new Subject(); - - /** - * We use this Replay subject to slow the amount of times we reload the UI - */ - private loadRecentlyAdded$: ReplaySubject = new ReplaySubject(); - - constructor(public accountService: AccountService, private libraryService: LibraryService, - private seriesService: SeriesService, private router: Router, - private titleService: Title, public imageService: ImageService, - private messageHub: MessageHubService) { - this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => { - if (res.event === EVENTS.SeriesAdded) { - const seriesAddedEvent = res.payload as SeriesAddedEvent; - - this.seriesService.getSeries(seriesAddedEvent.seriesId).subscribe(series => { - this.recentlyAddedSeries.unshift(series); - }); - } else if (res.event === EVENTS.SeriesRemoved) { - const seriesRemovedEvent = res.payload as SeriesRemovedEvent; - - this.inProgress = this.inProgress.filter(item => item.id != seriesRemovedEvent.seriesId); - this.recentlyAddedSeries = this.recentlyAddedSeries.filter(item => item.id != seriesRemovedEvent.seriesId); - this.recentlyUpdatedSeries = this.recentlyUpdatedSeries.filter(item => item.seriesId != seriesRemovedEvent.seriesId); - } else if (res.event === EVENTS.ScanSeries) { - // We don't have events for when series are updated, but we do get events when a scan update occurs. Refresh recentlyAdded at that time. - this.loadRecentlyAdded$.next(); - } - }); - - this.loadRecentlyAdded$.pipe(debounceTime(1000), takeUntil(this.onDestroy)).subscribe(() => this.loadRecentlyAdded()); - } - - ngOnInit(): void { - this.titleService.setTitle('Kavita - Dashboard'); - this.isLoading = true; - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - this.user = user; - if (this.user) { - this.isAdmin = this.accountService.hasAdminRole(this.user); - this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe(libraries => { - this.libraries = libraries; - this.isLoading = false; - }); - } - }); - - this.reloadSeries(); - } - - ngOnDestroy() { - this.onDestroy.next(); - this.onDestroy.complete(); - } - - reloadSeries() { - this.loadOnDeck(); - this.loadRecentlyAdded(); - this.loadRecentlyAddedSeries(); - } - - reloadInProgress(series: Series | boolean) { - if (series === true || series === false) { - if (!series) {return;} - } - // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request - const seriesObj = (series as Series); - if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) { - return; - } - - this.loadOnDeck(); - } - - loadOnDeck() { - let api = this.seriesService.getOnDeck(); - if (this.libraryId > 0) { - api = this.seriesService.getOnDeck(this.libraryId); - } - api.pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { - this.inProgress = updatedSeries.result; - }); - } - - loadRecentlyAddedSeries() { - let api = this.seriesService.getRecentlyAdded(); - if (this.libraryId > 0) { - api = this.seriesService.getRecentlyAdded(this.libraryId); - } - api.pipe(takeUntil(this.onDestroy)).subscribe((updatedSeries) => { - this.recentlyAddedSeries = updatedSeries.result; - }); - } - - - loadRecentlyAdded() { - let api = this.seriesService.getRecentlyUpdatedSeries(); - if (this.libraryId > 0) { - api = this.seriesService.getRecentlyUpdatedSeries(); - } - api.pipe(takeUntil(this.onDestroy)).subscribe(updatedSeries => { - this.recentlyUpdatedSeries = updatedSeries.filter(group => { - if (this.libraryId === 0) return true; - return group.libraryId === this.libraryId; - }); - }); - } - - handleRecentlyAddedChapterClick(item: RecentlyAddedItem) { - this.router.navigate(['library', item.libraryId, 'series', item.seriesId]); - } - - handleSectionClick(sectionTitle: string) { - if (sectionTitle.toLowerCase() === 'recently updated series') { - const params: any = {}; - params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc - params[FilterQueryParam.Page] = 1; - this.router.navigate(['all-series'], {queryParams: params}); - } else if (sectionTitle.toLowerCase() === 'on deck') { - const params: any = {}; - params[FilterQueryParam.ReadStatus] = 'true,false,false'; - params[FilterQueryParam.SortBy] = SortField.LastChapterAdded + ',false'; // sort by last chapter added, desc - params[FilterQueryParam.Page] = 1; - this.router.navigate(['all-series'], {queryParams: params}); - }else if (sectionTitle.toLowerCase() === 'newly added series') { - const params: any = {}; - params[FilterQueryParam.SortBy] = SortField.Created + ',false'; // sort by created, desc - params[FilterQueryParam.Page] = 1; - this.router.navigate(['all-series'], {queryParams: params}); - } - } - - removeFromArray(arr: Array, element: any) { - const index = arr.indexOf(element); - if (index >= 0) { - arr.splice(index); - } - } -} diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts index b179f4253..69afaa279 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts @@ -1,8 +1,8 @@ import { DOCUMENT } from '@angular/common'; import { Component, ElementRef, EventEmitter, Inject, Input, OnChanges, OnDestroy, OnInit, Output, Renderer2, SimpleChanges } from '@angular/core'; -import { BehaviorSubject, fromEvent, merge, ReplaySubject, Subject } from 'rxjs'; -import { debounceTime, take, takeUntil } from 'rxjs/operators'; -import { ScrollService } from 'src/app/scroll.service'; +import { BehaviorSubject, fromEvent, ReplaySubject, Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; +import { ScrollService } from 'src/app/_services/scroll.service'; import { ReaderService } from '../../_services/reader.service'; import { PAGING_DIRECTION } from '../_models/reader-enums'; import { WebtoonImage } from '../_models/webtoon-image'; diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.scss b/UI/Web/src/app/manga-reader/manga-reader.component.scss index a75e6e7a4..26c75bbcb 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.scss +++ b/UI/Web/src/app/manga-reader/manga-reader.component.scss @@ -54,10 +54,6 @@ img { } } -// canvas { -// //position: absolute; // JOE: Not sure why we have this, but it breaks the renderer -// } - .reader { background-color: var(--manga-reader-bg-color); overflow: auto; diff --git a/UI/Web/src/app/events-widget/events-widget.component.html b/UI/Web/src/app/nav/events-widget/events-widget.component.html similarity index 100% rename from UI/Web/src/app/events-widget/events-widget.component.html rename to UI/Web/src/app/nav/events-widget/events-widget.component.html diff --git a/UI/Web/src/app/events-widget/events-widget.component.scss b/UI/Web/src/app/nav/events-widget/events-widget.component.scss similarity index 100% rename from UI/Web/src/app/events-widget/events-widget.component.scss rename to UI/Web/src/app/nav/events-widget/events-widget.component.scss diff --git a/UI/Web/src/app/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/events-widget/events-widget.component.ts similarity index 87% rename from UI/Web/src/app/events-widget/events-widget.component.ts rename to UI/Web/src/app/nav/events-widget/events-widget.component.ts index 4478ce4a9..7261e397b 100644 --- a/UI/Web/src/app/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/events-widget/events-widget.component.ts @@ -2,16 +2,15 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { UpdateVersionEvent } from '../_models/events/update-version-event'; -import { User } from '../_models/user'; -import { AccountService } from '../_services/account.service'; -import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; -import { ErrorEvent } from '../_models/events/error-event'; -import { ConfirmService } from '../shared/confirm.service'; -import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config'; -import { ServerService } from '../_services/server.service'; +import { ConfirmConfig } from 'src/app/shared/confirm-dialog/_models/confirm-config'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { UpdateNotificationModalComponent } from 'src/app/shared/update-notification/update-notification-modal.component'; +import { ErrorEvent } from 'src/app/_models/events/error-event'; +import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event'; +import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event'; +import { User } from 'src/app/_models/user'; +import { AccountService } from 'src/app/_services/account.service'; +import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service'; @Component({ selector: 'app-nav-events-toggle', diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html similarity index 100% rename from UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.html rename to UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.html diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.scss similarity index 100% rename from UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.scss rename to UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.scss diff --git a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts similarity index 93% rename from UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts rename to UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts index 7110e8d02..7c986de57 100644 --- a/UI/Web/src/app/grouped-typeahead/grouped-typeahead.component.ts +++ b/UI/Web/src/app/nav/grouped-typeahead/grouped-typeahead.component.ts @@ -1,10 +1,9 @@ -import { DOCUMENT } from '@angular/common'; -import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild } from '@angular/core'; +import { Component, ContentChild, ElementRef, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output, TemplateRef, ViewChild } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import { debounceTime, takeUntil } from 'rxjs/operators'; -import { KEY_CODES } from '../shared/_services/utility.service'; -import { SearchResultGroup } from '../_models/search/search-result-group'; +import { KEY_CODES } from '../../shared/_services/utility.service'; +import { SearchResultGroup } from '../../_models/search/search-result-group'; @Component({ selector: 'app-grouped-typeahead', diff --git a/UI/Web/src/app/nav-header/nav-header.component.html b/UI/Web/src/app/nav/nav-header/nav-header.component.html similarity index 96% rename from UI/Web/src/app/nav-header/nav-header.component.html rename to UI/Web/src/app/nav/nav-header/nav-header.component.html index 2c2c6c69d..187b59643 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/nav-header/nav-header.component.html @@ -2,7 +2,7 @@
Skip to main content - Kavita + Kavita