Library Recomendations (#1236)

* Updated cover regex for finding cover images in archives to ignore back_cover or back-cover

* Fixed an issue where Tags wouldn't save due to not pulling them from the DB.

* Refactored All series to it's own lazy loaded module

* Modularized Dashboard and library detail. Had to change main dashboard page to be libraries. Subject to change.

* Refactored login component into registration module

* Series Detail module created

* Refactored nav stuff into it's own module, not lazy loaded, but self contained.

* Refactored theme component into a dev only module so we don't incur load for temp testing modules

* Finished off modularization code. Only missing thing is to re-introduce some dashboard functionality for library view.

* Implemented a basic recommendation page for library detail
This commit is contained in:
Joseph Milazzo 2022-04-29 17:27:01 -05:00 committed by GitHub
parent 743a3ba935
commit f237aa7ab7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 1077 additions and 501 deletions

View File

@ -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));

View File

@ -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;
}
/// <summary>
/// Quick Reads are series that are less than 2K pages in total.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <returns></returns>
[HttpGet("quick-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> 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);
}
/// <summary>
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <returns></returns>
[HttpGet("highly-rated")]
public async Task<ActionResult<PagedList<SeriesDto>>> 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);
}
/// <summary>
/// Chooses a random genre and shows series that are in that without reading progress
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <returns></returns>
[HttpGet("more-in")]
public async Task<ActionResult<PagedList<SeriesDto>>> 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);
}
/// <summary>
/// Series that are fully read by the user in no particular order
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <returns></returns>
[HttpGet("rediscover")]
public async Task<ActionResult<PagedList<SeriesDto>>> 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);
}
}

View File

@ -104,13 +104,17 @@ public interface ISeriesRepository
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
Task<Chunk> GetChunkInfo(int libraryId = 0);
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
Task<PagedList<SeriesDto>> 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<IEnumerable<SeriesDto>> 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<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId)
private IQueryable<int> GetLibraryIdsForUser(int userId)
{
return _context.AppUser
.Where(u => u.Id == userId)
.SelectMany(l => l.Libraries.Select(lib => lib.Id));
}
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> 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<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<RelatedSeriesDto> 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<int> GetSeriesIdsForLibraryIds(IQueryable<int> libraryIds)
{
return _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Id);
}
private async Task<IEnumerable<SeriesDto>> GetRelatedSeriesQuery(int seriesId, IEnumerable<int> usersSeriesIds, RelationKind kind)
{
return await _context.Series.SelectMany(s =>

View File

@ -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(@"(?<![[a-z]\d])(?:!?)((?<!back)cover|folder)(?![\w\d])",
private static readonly Regex CoverImageRegex = new Regex(@"(?<![[a-z]\d])(?:!?)(?<!back)(?<!back_)(?<!back-)(cover|folder)(?![\w\d])",
MatchOptions, RegexTimeout);
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+]",

View File

@ -285,14 +285,11 @@ public class SeriesService : ISeriesService
var isModified = false;
// I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
var existingTags = series.Metadata.Tags.ToList();
foreach (var existing in existingTags)
foreach (var existing in existingTags.Where(existing => 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.

View File

@ -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({

View File

@ -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'

View File

@ -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<PaginatedResult<Series[]>>(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<PaginatedResult<Series[]>>(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<PaginatedResult<Series[]>>(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<PaginatedResult<Series[]>>(this.baseUrl + 'recommended/more-in?libraryId=' + libraryId + '&genreId=' + genreId, {observe: 'response', params})
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
}
}

View File

@ -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<Series[]> = new PaginatedResult<Series[]>();
paginatedSeriesForTagsResults: PaginatedResult<Series[]> = new PaginatedResult<Series[]>();
constructor(private httpClient: HttpClient, private imageService: ImageService) { }
_cachePaginatedResults(response: any, paginatedVariable: PaginatedResult<any[]>) {
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<PaginatedResult<Series[]>>(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<PaginatedResult<Series[]>>(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<Series[]>(this.baseUrl + 'series/recently-added?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => {
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
})
);
}
@ -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<Series[]>(this.baseUrl + 'series/on-deck?libraryId=' + libraryId, data, {observe: 'response', params}).pipe(
map(response => {
return this._cachePaginatedResults(response, new PaginatedResult<Series[]>());
return this.utilityService.createPaginatedResult(response, new PaginatedResult<Series[]>());
}));
}
@ -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<PaginatedResult<Series[]>>(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<SeriesDetail>(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;

View File

@ -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';

View File

@ -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 { }

View File

@ -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';

View File

@ -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 { }

View File

@ -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 { }

View File

@ -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',

View File

@ -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;
}

View File

@ -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
]

View File

@ -54,7 +54,6 @@ import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-
MetadataFilterModule,
NgbNavModule,
NgbTooltipModule, // Card item
NgbCollapseModule,
NgbRatingModule,

View File

@ -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

View File

@ -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 { }

View File

@ -1,3 +1,31 @@
<app-side-nav-companion-bar>
</app-side-nav-companion-bar>
<app-library></app-library>
<ng-container *ngIf="libraries.length === 0 && !isLoading">
<div class="mt-3">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>There are no libraries setup yet. Configure some in <a routerLink="/admin/dashboard" fragment="libraries">Server settings</a>.</p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>You haven't been granted access to any libraries.</p>
</div>
</div>
</ng-container>
<app-carousel-reel [items]="inProgress" title="On Deck" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-card-item [entity]="item" [title]="item.seriesName" [suppressLibraryLink]="libraryId !== 0" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="recentlyAddedSeries" title="Newly Added Series" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
</ng-template>
</app-carousel-reel>

View File

@ -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<void>();
/**
* We use this Replay subject to slow the amount of times we reload the UI
*/
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
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<any>, element: any) {
const index = arr.indexOf(element);
if (index >= 0) {
arr.splice(index);
}
}
}

View File

@ -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 { }

View File

@ -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 { }

View File

@ -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 { }

View File

@ -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',

View File

@ -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 { }

View File

@ -10,7 +10,7 @@
<a ngbNavLink>{{tab.title | sentenceCase}}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.title === 'Recommended'">
<app-library [libraryId]="libraryId"></app-library>
<app-library-recommended [libraryId]="libraryId"></app-library-recommended>
</ng-container>
<ng-container *ngIf="tab.title === 'Library'">
<app-card-detail-layout

View File

@ -2,9 +2,8 @@ import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angul
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Subject } from 'rxjs';
import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators';
import { debounceTime, take, takeUntil } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { FilterSettings } from '../metadata-filter/filter-settings';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { Library } from '../_models/library';
@ -18,6 +17,7 @@ import { EVENTS, MessageHubService } from '../_services/message-hub.service';
import { SeriesService } from '../_services/series.service';
import { NavService } from '../_services/nav.service';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { FilterSettings } from '../metadata-filter/filter-settings';
@Component({
selector: 'app-library-detail',
@ -87,8 +87,9 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService,
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) {
const routeId = this.route.snapshot.paramMap.get('id');
const routeId = this.route.snapshot.paramMap.get('libraryId');
if (routeId === null) {
console.log('Redirecting due to not seeing libraryId in route');
this.router.navigateByUrl('/libraries');
return;
}

View File

@ -0,0 +1,28 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LibraryDetailComponent } from './library-detail.component';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { PipeModule } from '../pipe/pipe.module';
import { LibraryDetailRoutingModule } from './library-detail-routing.module';
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
import { LibraryRecommendedComponent } from './library-recommended/library-recommended.component';
import { CarouselModule } from '../carousel/carousel.module';
@NgModule({
declarations: [LibraryDetailComponent, LibraryRecommendedComponent],
imports: [
CommonModule,
NgbNavModule,
CarouselModule, // because this is heavy, we might want recommended in a new url
PipeModule,
SharedSideNavCardsModule,
LibraryDetailRoutingModule
]
})
export class LibraryDetailModule { }

View File

@ -0,0 +1,42 @@
<ng-container *ngIf="onDeck$ | async as onDeck">
<app-carousel-reel [items]="onDeck" title="On Deck">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="quickReads$ | async as quickReads">
<app-carousel-reel [items]="quickReads" title="Quick Reads">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="highlyRated$ | async as highlyRated">
<app-carousel-reel [items]="highlyRated" title="Highly Rated">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="rediscover$ | async as rediscover">
<app-carousel-reel [items]="rediscover" title="Rediscover">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
<ng-container *ngIf="genre$ | async as genre">
<ng-container *ngIf="moreIn$ | async as moreIn">
<app-carousel-reel [items]="moreIn" title="More In {{genre.title}}">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
</ng-container>

View File

@ -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<Series[]>;
highlyRated$!: Observable<Series[]>;
onDeck$!: Observable<Series[]>;
rediscover$!: Observable<Series[]>;
moreIn$!: Observable<Series[]>;
genre: string = '';
genre$!: Observable<Genre>;
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();
}
}

View File

@ -1,29 +0,0 @@
<ng-container *ngIf="libraries.length === 0 && !isLoading">
<div class="mt-3">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>There are no libraries setup yet. Configure some in <a routerLink="/admin/dashboard" fragment="libraries">Server settings</a>.</p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>You haven't been granted access to any libraries.</p>
</div>
</div>
</ng-container>
<app-carousel-reel [items]="inProgress" title="On Deck" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"></app-series-card>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="recentlyUpdatedSeries" title="Recently Updated Series" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-card-item [entity]="item" [title]="item.seriesName" [suppressLibraryLink]="libraryId !== 0" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[supressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
<app-carousel-reel [items]="recentlyAddedSeries" title="Newly Added Series" (sectionClick)="handleSectionClick($event)">
<ng-template #carouselItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="item.libraryId" [suppressLibraryLink]="libraryId !== 0" (dataChanged)="loadRecentlyAddedSeries()"></app-series-card>
</ng-template>
</app-carousel-reel>

View File

@ -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<void>();
/**
* We use this Replay subject to slow the amount of times we reload the UI
*/
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
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<any>, element: any) {
const index = arr.indexOf(element);
if (index >= 0) {
arr.splice(index);
}
}
}

View File

@ -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';

View File

@ -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;

View File

@ -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',

View File

@ -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',

View File

@ -2,7 +2,7 @@
<div class="container-fluid">
<a class="visually-hidden-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<a class="side-nav-toggle" *ngIf="navService?.sideNavVisibility$ | async" (click)="hideSideNav()"><i class="fas fa-bars"></i></a>
<a class="navbar-brand dark-exempt" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="d-none d-md-inline"> Kavita</span></a>
<a class="navbar-brand dark-exempt" routerLink="/libraries" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="d-none d-md-inline"> Kavita</span></a>
<ul class="navbar-nav col me-auto">
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">

View File

@ -1,20 +1,20 @@
import { DOCUMENT } from '@angular/common';
import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { fromEvent, Subject } from 'rxjs';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ScrollService } from '../scroll.service';
import { FilterQueryParam } from '../shared/_services/filter-utilities.service';
import { CollectionTag } from '../_models/collection-tag';
import { Library } from '../_models/library';
import { PersonRole } from '../_models/person';
import { ReadingList } from '../_models/reading-list';
import { SearchResult } from '../_models/search-result';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { AccountService } from '../_services/account.service';
import { ImageService } from '../_services/image.service';
import { LibraryService } from '../_services/library.service';
import { NavService } from '../_services/nav.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { CollectionTag } from '../../_models/collection-tag';
import { Library } from '../../_models/library';
import { PersonRole } from '../../_models/person';
import { ReadingList } from '../../_models/reading-list';
import { SearchResult } from '../../_models/search-result';
import { SearchResultGroup } from '../../_models/search/search-result-group';
import { AccountService } from '../../_services/account.service';
import { ImageService } from '../../_services/image.service';
import { LibraryService } from '../../_services/library.service';
import { NavService } from '../../_services/nav.service';
@Component({
selector: 'app-nav-header',

View File

@ -0,0 +1,37 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { EventsWidgetComponent } from './events-widget/events-widget.component';
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { NgbDropdownModule, NgbPopoverModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
declarations: [
NavHeaderComponent,
EventsWidgetComponent,
GroupedTypeaheadComponent,
],
imports: [
CommonModule,
ReactiveFormsModule,
NgbDropdownModule,
NgbPopoverModule,
NgbNavModule,
SharedModule, // app image, series-format
PipeModule,
TypeaheadModule,
],
exports: [
NavHeaderComponent,
SharedModule
]
})
export class NavModule { }

View File

@ -2,17 +2,16 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DragableOrderedListComponent } from './dragable-ordered-list/dragable-ordered-list.component';
import { ReadingListDetailComponent } from './reading-list-detail/reading-list-detail.component';
import { ReadingListRoutingModule } from './reading-list.router.module';
import { ReadingListRoutingModule } from './reading-list-routing.module';
import {DragDropModule} from '@angular/cdk/drag-drop';
import { AddToListModalComponent } from './_modals/add-to-list-modal/add-to-list-modal.component';
import { ReactiveFormsModule } from '@angular/forms';
import { CardsModule } from '../cards/cards.module';
import { ReadingListsComponent } from './reading-lists/reading-lists.component';
import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.component';
import { PipeModule } from '../pipe/pipe.module';
import { SharedModule } from '../shared/shared.module';
import { SidenavModule } from '../sidenav/sidenav.module';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
@ -26,14 +25,15 @@ import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
],
imports: [
CommonModule,
ReadingListRoutingModule,
ReactiveFormsModule,
DragDropModule,
CardsModule,
NgbNavModule,
PipeModule,
SharedModule,
SidenavModule,
NgbNavModule
SharedSideNavCardsModule,
ReadingListRoutingModule,
],
exports: [
AddToListModalComponent,

View File

@ -1,8 +1,8 @@
import { Component, Input, OnInit } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ThemeService } from 'src/app/theme.service';
import { ThemeService } from 'src/app/_services/theme.service';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';

View File

@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { ThemeService } from 'src/app/theme.service';
import { ThemeService } from 'src/app/_services/theme.service';
import { AccountService } from 'src/app/_services/account.service';
@Component({

View File

@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ConfirmEmailComponent } from './confirm-email/confirm-email.component';
import { RegistrationRoutingModule } from './registration.router.module';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { SplashContainerComponent } from './splash-container/splash-container.component';
import { RegisterComponent } from './register/register.component';
@ -10,6 +10,7 @@ import { AddEmailToAccountMigrationModalComponent } from './add-email-to-account
import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
import { ResetPasswordComponent } from './reset-password/reset-password.component';
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
import { UserLoginComponent } from './user-login/user-login.component';
@ -21,7 +22,8 @@ import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-
AddEmailToAccountMigrationModalComponent,
ConfirmMigrationEmailComponent,
ResetPasswordComponent,
ConfirmResetPasswordComponent
ConfirmResetPasswordComponent,
UserLoginComponent
],
imports: [
CommonModule,

View File

@ -5,8 +5,17 @@ import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confir
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
import { RegisterComponent } from './register/register.component';
import { ResetPasswordComponent } from './reset-password/reset-password.component';
import { UserLoginComponent } from './user-login/user-login.component';
const routes: Routes = [
{
path: '',
component: UserLoginComponent
},
{
path: 'login',
component: UserLoginComponent
},
{
path: 'confirm-email',
component: ConfirmEmailComponent,

View File

@ -1,5 +1,5 @@
import { of } from 'rxjs';
import { MemberService } from '../_services/member.service';
import { MemberService } from '../../_services/member.service';
import { UserLoginComponent } from './user-login.component';
xdescribe('UserLoginComponent', () => {

View File

@ -4,14 +4,14 @@ import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { SettingsService } from '../admin/settings.service';
import { AddEmailToAccountMigrationModalComponent } from '../registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { MemberService } from '../_services/member.service';
import { NavService } from '../_services/nav.service';
import { SettingsService } from '../../admin/settings.service';
import { AddEmailToAccountMigrationModalComponent } from '../add-email-to-account-migration-modal/add-email-to-account-migration-modal.component';
import { User } from '../../_models/user';
import { AccountService } from '../../_services/account.service';
import { MemberService } from '../../_services/member.service';
import { NavService } from '../../_services/nav.service';
// TODO: Move this into registration module
@Component({
selector: 'app-user-login',
templateUrl: './user-login.component.html',
@ -48,7 +48,7 @@ export class UserLoginComponent implements OnInit {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.navService.showSideNav();
this.router.navigateByUrl('/library');
this.router.navigateByUrl('/libraries');
}
});
@ -96,7 +96,7 @@ export class UserLoginComponent implements OnInit {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
this.router.navigateByUrl('/library');
this.router.navigateByUrl('/libraries');
}
}, err => {
if (err.error === 'You are missing an email on your account. Please wait while we migrate your account.') {

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SeriesDetailComponent } from './series-detail.component';
const routes: Routes = [
{
path: '',
component: SeriesDetailComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class SeriesDetailRoutingModule { }

View File

@ -13,7 +13,7 @@ import { ConfirmService } from '../shared/confirm.service';
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
import { DownloadService } from '../shared/_services/download.service';
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
import { ReviewSeriesModalComponent } from '../_modals/review-series-modal/review-series-modal.component';
import { ReviewSeriesModalComponent } from './review-series-modal/review-series-modal.component';
import { Chapter } from '../_models/chapter';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { SeriesRemovedEvent } from '../_models/events/series-removed-event';
@ -33,8 +33,8 @@ import { ReaderService } from '../_services/reader.service';
import { ReadingListService } from '../_services/reading-list.service';
import { SeriesService } from '../_services/series.service';
import { NavService } from '../_services/nav.service';
import { RelationKind } from '../_models/series-detail/relation-kind';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { RelationKind } from '../_models/series-detail/relation-kind';
interface RelatedSeris {
series: Series;

View File

@ -0,0 +1,38 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesDetailRoutingModule } from './series-detail-routing.module';
import { NgbCollapseModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { SeriesDetailComponent } from './series-detail.component';
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
import { ReviewSeriesModalComponent } from './review-series-modal/review-series-modal.component';
import { SharedModule } from '../shared/shared.module';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { PipeModule } from '../pipe/pipe.module';
import { ReactiveFormsModule } from '@angular/forms';
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
@NgModule({
declarations: [
SeriesDetailComponent,
ReviewSeriesModalComponent,
SeriesMetadataDetailComponent
],
imports: [
CommonModule,
ReactiveFormsModule, // Review Series Modal
NgbCollapseModule, // Series Metadata
NgbNavModule,
NgbRatingModule,
TypeaheadModule,
PipeModule,
SharedModule, // person badge, badge expander (these 2 can be their own module)
SharedSideNavCardsModule,
SeriesDetailRoutingModule
]
})
export class SeriesDetailModule { }

View File

@ -1,13 +1,13 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { Router } from '@angular/router';
import { TagBadgeCursor } from '../shared/tag-badge/tag-badge.component';
import { FilterQueryParam } from '../shared/_services/filter-utilities.service';
import { UtilityService } from '../shared/_services/utility.service';
import { MangaFormat } from '../_models/manga-format';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { SeriesMetadata } from '../_models/series-metadata';
import { MetadataService } from '../_services/metadata.service';
import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { UtilityService } from '../../shared/_services/utility.service';
import { MangaFormat } from '../../_models/manga-format';
import { ReadingList } from '../../_models/reading-list';
import { Series } from '../../_models/series';
import { SeriesMetadata } from '../../_models/series-metadata';
import { MetadataService } from '../../_services/metadata.service';
@Component({
selector: 'app-series-metadata-detail',

View File

@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CardsModule } from '../cards/cards.module';
import { SidenavModule } from '../sidenav/sidenav.module';
/**
* Exports SideNavModule and CardsModule
*/
@NgModule({
declarations: [],
imports: [
CommonModule,
CardsModule,
SidenavModule,
],
exports: [
CardsModule,
SidenavModule
]
})
export class SharedSideNavCardsModule { }

View File

@ -1,8 +1,10 @@
import { HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library';
import { MangaFormat } from 'src/app/_models/manga-format';
import { PaginatedResult } from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series';
import { SeriesFilter, SortField } from 'src/app/_models/series-filter';
import { Volume } from 'src/app/_models/volume';
@ -201,4 +203,30 @@ export class UtilityService {
private isObject(object: any) {
return object != null && typeof object === 'object';
}
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;
}
createPaginatedResult(response: any, paginatedVariable: PaginatedResult<any[]> | undefined = undefined) {
if (paginatedVariable === undefined) {
paginatedVariable = new 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;
}
}

View File

@ -38,21 +38,24 @@ import { PipeModule } from '../pipe/pipe.module';
RouterModule,
ReactiveFormsModule,
NgbCollapseModule,
NgbTooltipModule, // RegisterMemberComponent
NgbTooltipModule, // TODO: Validate if we still need this
PipeModule,
NgCircleProgressModule.forRoot(),
],
exports: [
ReadMoreComponent, // Used globably
DrawerComponent, // Can be replaced with boostrap offscreen canvas (v5)
ShowIfScrollbarDirective, // Used book reader only?
DrawerComponent, // Can be replaced with boostrap offscreen canvas (v5) (also used in book reader and series metadata filter)
A11yClickDirective, // Used globally
SeriesFormatComponent, // Used globally
TagBadgeComponent, // Used globally
CircularLoaderComponent, // Used in Cards only
ImageComponent, // Used globally
ShowIfScrollbarDirective, // Used book reader only?
PersonBadgeComponent, // Used Series Detail
BadgeExpanderComponent, // Used globally
ImageComponent // Used globally
BadgeExpanderComponent, // Used Series Detail/Metadata
],
})
export class SharedModule { }

View File

@ -107,46 +107,46 @@ a {
@media (max-width: 576px) {
.side-nav-item {
align-items: center;
display: flex;
justify-content: space-between;
//display: flex;
//justify-content: space-between;
padding: 15px 10px;
width: 100%;
//width: 100%;
height: 70px;
min-height: 40px;
overflow: hidden;
//min-height: 40px;
// overflow: hidden;
font-size: 1rem;
cursor: pointer;
//cursor: pointer;
.side-nav-text {
padding-left: 10px;
opacity: 1;
min-width: 100px;
// padding-left: 10px;
// opacity: 1;
// min-width: 100px;
width: 100%;
div {
min-width: 102px;
width: 100%
}
// div {
// min-width: 102px;
// width: 100%
// }
}
&.closed {
.side-nav-text {
opacity: 0;
}
// .side-nav-text {
// opacity: 0;
// }
.card-actions {
opacity: 0;
font-size: inherit
//opacity: 0;
font-size: inherit;
}
}
span {
&:last-child {
flex-grow: 1;
justify-content: end;
}
}
// span {
// &:last-child {
// flex-grow: 1;
// justify-content: end;
// }
// }
}
}

View File

@ -7,7 +7,7 @@
</ng-container>
</app-side-nav-item> -->
<app-side-nav-item icon="fa-home" title="Home" link="/library/"></app-side-nav-item>
<app-side-nav-item icon="fa-home" title="Home" link="/libraries/"></app-side-nav-item>
<app-side-nav-item icon="fa-list" title="Collections" link="/collections/"></app-side-nav-item>
<app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists/"></app-side-nav-item>
<app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item>

View File

@ -25,7 +25,7 @@
border: var(--side-nav-border-closed);
}
.side-nav-item:first() {
.side-nav-item:first {
border-top-left-radius: var(--side-nav-border-radius);
border-top-right-radius: var(--side-nav-border-radius);
}
@ -52,7 +52,7 @@
box-shadow: none;
}
.side-nav-item:first() {
.side-nav-item:first {
border-top-left-radius: var(--side-nav-border-radius);
border-top-right-radius: var(--side-nav-border-radius);
}

View File

@ -1,7 +1,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { distinctUntilChanged, Subject, take, takeUntil } from 'rxjs';
import { ThemeService } from 'src/app/theme.service';
import { ThemeService } from 'src/app/_services/theme.service';
import { SiteTheme, ThemeProvider } from 'src/app/_models/preferences/site-theme';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';

View File

@ -9,8 +9,8 @@ import { ApiKeyComponent } from './api-key/api-key.component';
import { PipeModule } from '../pipe/pipe.module';
import { SiteThemeProviderPipe } from './_pipes/site-theme-provider.pipe';
import { ThemeManagerComponent } from './theme-manager/theme-manager.component';
import { SidenavModule } from '../sidenav/sidenav.module';
import { ColorPickerModule } from 'ngx-color-picker';
import { SidenavModule } from '../sidenav/sidenav.module';
@ -24,14 +24,18 @@ import { ColorPickerModule } from 'ngx-color-picker';
imports: [
CommonModule,
ReactiveFormsModule,
NgbAccordionModule,
NgbNavModule,
NgbTooltipModule,
NgxSliderModule,
UserSettingsRoutingModule,
ColorPickerModule, // User prefernces background color
PipeModule,
SidenavModule,
ColorPickerModule, // User prefernces background color
UserSettingsRoutingModule,
],
exports: [
SiteThemeProviderPipe,