Feature/bookmark feedback (#508)

* ImageService had a stream reset before writting out to array. Added logging statment for updating series metadata. Removed ConcurencyCheck due to bad update issue for CollectionTag.

* Added a new screen which lets you quickly see all your bookmarks for a given user.

* Built user bookmark page in user settings. Moved user settings to it's own lazy loaded module. Removed unneded debouncing from downloader and just used throttleTime instead.

* Removed a not-yet implemented tab from series modal

* Fixed a bug in clear bookmarks and adjusted icons within anchors to have proper styling
This commit is contained in:
Joseph Milazzo 2021-08-18 17:16:05 -07:00 committed by GitHub
parent 623e555633
commit 68bb5ed5a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 554 additions and 270 deletions

View File

@ -11,6 +11,7 @@ using API.Extensions;
using API.Interfaces; using API.Interfaces;
using API.Interfaces.Services; using API.Interfaces.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers namespace API.Controllers
{ {
@ -22,16 +23,18 @@ namespace API.Controllers
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService; private readonly ICacheService _cacheService;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderController> _logger;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer(); private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
/// <inheritdoc /> /// <inheritdoc />
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork) public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger<ReaderController> logger)
{ {
_directoryService = directoryService; _directoryService = directoryService;
_cacheService = cacheService; _cacheService = cacheService;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger;
} }
/// <summary> /// <summary>
@ -350,19 +353,31 @@ namespace API.Controllers
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId)); return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
} }
/// <summary>
/// Returns a list of all bookmarked pages for a User
/// </summary>
/// <returns></returns>
[HttpGet("get-all-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id));
}
/// <summary> /// <summary>
/// Removes all bookmarks for all chapters linked to a Series /// Removes all bookmarks for all chapters linked to a Series
/// </summary> /// </summary>
/// <param name="seriesId"></param> /// <param name="seriesId"></param>
/// <returns></returns> /// <returns></returns>
[HttpPost("remove-bookmarks")] [HttpPost("remove-bookmarks")]
public async Task<ActionResult> RemoveBookmarks(int seriesId) public async Task<ActionResult> RemoveBookmarks(RemoveBookmarkForSeriesDto dto)
{ {
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok("Nothing to remove"); if (user.Bookmarks == null) return Ok("Nothing to remove");
try try
{ {
user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId == seriesId).ToList(); user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList();
_unitOfWork.UserRepository.Update(user); _unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync()) if (await _unitOfWork.CommitAsync())
@ -370,8 +385,9 @@ namespace API.Controllers
return Ok(); return Ok();
} }
} }
catch (Exception) catch (Exception ex)
{ {
_logger.LogError(ex, "There was an exception when trying to clear bookmarks");
await _unitOfWork.RollbackAsync(); await _unitOfWork.RollbackAsync();
} }

View File

@ -12,6 +12,7 @@ using API.Interfaces;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Controllers namespace API.Controllers
@ -154,8 +155,8 @@ namespace API.Controllers
} }
series.Name = updateSeries.Name.Trim(); series.Name = updateSeries.Name.Trim();
series.LocalizedName = updateSeries.LocalizedName.Trim(); series.LocalizedName = updateSeries.LocalizedName.Trim();
series.SortName = updateSeries.SortName.Trim(); series.SortName = updateSeries.SortName?.Trim();
series.Summary = updateSeries.Summary.Trim(); series.Summary = updateSeries.Summary?.Trim(); // BUG: There was an exceptionSystem.NullReferenceException: Object reference not set to an instance of an object.
var needsRefreshMetadata = false; var needsRefreshMetadata = false;
if (!updateSeries.CoverImageLocked) if (!updateSeries.CoverImageLocked)
@ -296,8 +297,9 @@ namespace API.Controllers
return Ok("Successfully updated"); return Ok("Successfully updated");
} }
} }
catch (Exception) catch (Exception ex)
{ {
_logger.LogError(ex, "There was an exception when updating metadata");
await _unitOfWork.RollbackAsync(); await _unitOfWork.RollbackAsync();
} }
@ -327,6 +329,19 @@ namespace API.Controllers
return Ok(series); return Ok(series);
} }
/// <summary>
/// Fetches Series for a set of Ids. This will check User for permission access and filter out any Ids that don't exist or
/// the user does not have access to.
/// </summary>
/// <returns></returns>
[HttpPost("series-by-ids")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesById(SeriesByIdsDto dto)
{
if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds");
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, user.Id));
}
} }
} }

View File

@ -0,0 +1,7 @@
namespace API.DTOs
{
public class RemoveBookmarkForSeriesDto
{
public int SeriesId { get; init; }
}
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs
{
public class SeriesByIdsDto
{
public int[] SeriesIds { get; init; }
}
}

View File

@ -52,7 +52,7 @@ namespace API.Data
IsSpecial = specialTreatment, IsSpecial = specialTreatment,
}; };
} }
public static SeriesMetadata SeriesMetadata(ICollection<CollectionTag> collectionTags) public static SeriesMetadata SeriesMetadata(ICollection<CollectionTag> collectionTags)
{ {
return new SeriesMetadata() return new SeriesMetadata()
@ -66,11 +66,11 @@ namespace API.Data
return new CollectionTag() return new CollectionTag()
{ {
Id = id, Id = id,
NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(), NormalizedTitle = API.Parser.Parser.Normalize(title?.Trim()).ToUpper(),
Title = title, Title = title?.Trim(),
Summary = summary, Summary = summary?.Trim(),
Promoted = promoted Promoted = promoted
}; };
} }
} }
} }

View File

@ -443,5 +443,21 @@ namespace API.Data
.AsNoTracking() .AsNoTracking()
.ToListAsync(); .ToListAsync();
} }
public async Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId)
{
var allowedLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(x => x.Id == userId))
.Select(l => l.Id);
return await _context.Series
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.OrderBy(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync();
}
} }
} }

View File

@ -106,6 +106,17 @@ namespace API.Data
.ToListAsync(); .ToListAsync();
} }
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync() public async Task<IEnumerable<MemberDto>> GetMembersAsync()
{ {
return await _context.Users return await _context.Users

View File

@ -9,7 +9,7 @@ namespace API.Entities
/// Represents a user entered field that is used as a tagging and grouping mechanism /// Represents a user entered field that is used as a tagging and grouping mechanism
/// </summary> /// </summary>
[Index(nameof(Id), nameof(Promoted), IsUnique = true)] [Index(nameof(Id), nameof(Promoted), IsUnique = true)]
public class CollectionTag : IHasConcurrencyToken public class CollectionTag
{ {
public int Id { get; set; } public int Id { get; set; }
/// <summary> /// <summary>
@ -42,12 +42,14 @@ namespace API.Entities
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; } public ICollection<SeriesMetadata> SeriesMetadatas { get; set; }
/// <summary>
/// <inheritdoc /> /// Not Used due to not using concurrency update
[ConcurrencyCheck] /// </summary>
public uint RowVersion { get; private set; } public uint RowVersion { get; private set; }
/// <inheritdoc /> /// <summary>
/// Not Used due to not using concurrency update
/// </summary>
public void OnSavingChanges() public void OnSavingChanges()
{ {
RowVersion++; RowVersion++;

View File

@ -63,5 +63,6 @@ namespace API.Interfaces
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId); Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId); Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId);
} }
} }

View File

@ -19,5 +19,6 @@ namespace API.Interfaces
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
} }
} }

View File

@ -57,6 +57,7 @@ namespace API.Services
using var img = Image.NewFromFile(path); using var img = Image.NewFromFile(path);
using var stream = new MemoryStream(); using var stream = new MemoryStream();
img.JpegsaveStream(stream); img.JpegsaveStream(stream);
stream.Position = 0;
return stream.ToArray(); return stream.ToArray();
} }
catch (Exception ex) catch (Exception ex)

View File

@ -28,6 +28,10 @@ export class ReaderService {
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page}); return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
} }
getAllBookmarks() {
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-all-bookmarks');
}
getBookmarks(chapterId: number) { getBookmarks(chapterId: number) {
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-bookmarks?chapterId=' + chapterId); return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-bookmarks?chapterId=' + chapterId);
} }

View File

@ -52,6 +52,10 @@ export class SeriesService {
); );
} }
getAllSeriesByIds(seriesIds: Array<number>) {
return this.httpClient.post<Series[]>(this.baseUrl + 'series/series-by-ids', {seriesIds: seriesIds});
}
getSeries(seriesId: number) { getSeries(seriesId: number) {
return this.httpClient.get<Series>(this.baseUrl + 'series/' + seriesId); return this.httpClient.get<Series>(this.baseUrl + 'series/' + seriesId);
} }

View File

@ -20,6 +20,6 @@
</ng-template> </ng-template>
</li> </li>
</ul> </ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div> <div [ngbNavOutlet]="nav" class="mt-3"></div>
</div> </div>

View File

@ -1,8 +1,7 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { asyncScheduler } from 'rxjs'; import { finalize, take, takeWhile } from 'rxjs/operators';
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
import { DownloadService } from 'src/app/shared/_services/download.service'; import { DownloadService } from 'src/app/shared/_services/download.service';
import { ServerService } from 'src/app/_services/server.service'; import { ServerService } from 'src/app/_services/server.service';
import { SettingsService } from '../settings.service'; import { SettingsService } from '../settings.service';
@ -92,7 +91,6 @@ export class ManageSystemComponent implements OnInit {
downloadLogs() { downloadLogs() {
this.downloadLogsInProgress = true; this.downloadLogsInProgress = true;
this.downloadService.downloadLogs().pipe( this.downloadService.downloadLogs().pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => { takeWhile(val => {
return val.state != 'DONE'; return val.state != 'DONE';
}), }),

View File

@ -7,7 +7,6 @@ import { NotConnectedComponent } from './not-connected/not-connected.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component'; import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { RecentlyAddedComponent } from './recently-added/recently-added.component';
import { UserLoginComponent } from './user-login/user-login.component'; import { UserLoginComponent } from './user-login/user-login.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AuthGuard } from './_guards/auth.guard'; import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard';
import { InProgressComponent } from './in-progress/in-progress.component'; import { InProgressComponent } from './in-progress/in-progress.component';
@ -24,6 +23,10 @@ const routes: Routes = [
path: 'collections', path: 'collections',
loadChildren: () => import('./collections/collections.module').then(m => m.CollectionsModule) loadChildren: () => import('./collections/collections.module').then(m => m.CollectionsModule)
}, },
{
path: 'preferences',
loadChildren: () => import('./user-settings/user-settings.module').then(m => m.UserSettingsModule)
},
{ {
path: '', path: '',
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
@ -49,7 +52,6 @@ const routes: Routes = [
{path: 'library', component: LibraryComponent}, {path: 'library', component: LibraryComponent},
{path: 'recently-added', component: RecentlyAddedComponent}, {path: 'recently-added', component: RecentlyAddedComponent},
{path: 'in-progress', component: InProgressComponent}, {path: 'in-progress', component: InProgressComponent},
{path: 'preferences', component: UserPreferencesComponent},
] ]
}, },
{path: 'login', component: UserLoginComponent}, {path: 'login', component: UserLoginComponent},

View File

@ -18,7 +18,6 @@ import { SharedModule } from './shared/shared.module';
import { LibraryDetailComponent } from './library-detail/library-detail.component'; import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component'; import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { NotConnectedComponent } from './not-connected/not-connected.component'; import { NotConnectedComponent } from './not-connected/not-connected.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { AutocompleteLibModule } from 'angular-ng-autocomplete'; import { AutocompleteLibModule } from 'angular-ng-autocomplete';
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
import { CarouselModule } from './carousel/carousel.module'; import { CarouselModule } from './carousel/carousel.module';
@ -37,6 +36,8 @@ import { RecentlyAddedComponent } from './recently-added/recently-added.componen
import { InProgressComponent } from './in-progress/in-progress.component'; import { InProgressComponent } from './in-progress/in-progress.component';
import { CardsModule } from './cards/cards.module'; import { CardsModule } from './cards/cards.module';
import { CollectionsModule } from './collections/collections.module'; import { CollectionsModule } from './collections/collections.module';
import { CommonModule } from '@angular/common';
import { SAVER, getSaver } from './shared/_providers/saver.provider';
let sentryProviders: any[] = []; let sentryProviders: any[] = [];
@ -93,7 +94,6 @@ if (environment.production) {
LibraryDetailComponent, LibraryDetailComponent,
SeriesDetailComponent, SeriesDetailComponent,
NotConnectedComponent, // Move into ExtrasModule NotConnectedComponent, // Move into ExtrasModule
UserPreferencesComponent, // Move into SettingsModule
ReviewSeriesModalComponent, ReviewSeriesModalComponent,
PersonBadgeComponent, PersonBadgeComponent,
RecentlyAddedComponent, RecentlyAddedComponent,
@ -109,11 +109,11 @@ if (environment.production) {
NgbDropdownModule, // Nav NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav AutocompleteLibModule, // Nav
NgbTooltipModule, // Shared & SettingsModule //NgbTooltipModule, // Shared & SettingsModule
NgbRatingModule, // Series Detail NgbRatingModule, // Series Detail
NgbNavModule, NgbNavModule,
NgbAccordionModule, // User Preferences //NgbAccordionModule, // User Preferences
NgxSliderModule, // User Preference //NgxSliderModule, // User Preference
NgbPaginationModule, NgbPaginationModule,
@ -135,6 +135,7 @@ if (environment.production) {
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},
{provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true},
Title, Title,
{provide: SAVER, useFactory: getSaver},
...sentryProviders, ...sentryProviders,
], ],
entryComponents: [], entryComponents: [],

View File

@ -1,8 +1,7 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { asyncScheduler } from 'rxjs'; import { finalize, take, takeWhile } from 'rxjs/operators';
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
import { DownloadService } from 'src/app/shared/_services/download.service'; import { DownloadService } from 'src/app/shared/_services/download.service';
import { PageBookmark } from 'src/app/_models/page-bookmark'; import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
@ -55,7 +54,6 @@ export class BookmarksModalComponent implements OnInit {
downloadBookmarks() { downloadBookmarks() {
this.isDownloading = true; this.isDownloading = true;
this.downloadService.downloadBookmarks(this.bookmarks).pipe( this.downloadService.downloadBookmarks(this.bookmarks).pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => { takeWhile(val => {
return val.state != 'DONE'; return val.state != 'DONE';
}), }),

View File

@ -82,12 +82,6 @@
</li> </li>
<li [ngbNavItem]="tabs[1]"> <li [ngbNavItem]="tabs[1]">
<a ngbNavLink>{{tabs[1]}}</a> <a ngbNavLink>{{tabs[1]}}</a>
<ng-template ngbNavContent>
<p>Not Yet implemented</p>
</ng-template>
</li>
<li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<p class="alert alert-primary" role="alert"> <p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover. Upload and choose a new cover image. Press Save to upload and override the cover.
@ -95,8 +89,8 @@
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser> <app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="series.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template> </ng-template>
</li> </li>
<li [ngbNavItem]="tabs[3]"> <li [ngbNavItem]="tabs[2]">
<a ngbNavLink>{{tabs[3]}}</a> <a ngbNavLink>{{tabs[2]}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<h4>Information</h4> <h4>Information</h4>
<div class="row no-gutters mb-2"> <div class="row no-gutters mb-2">

View File

@ -28,7 +28,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
isCollapsed = true; isCollapsed = true;
volumeCollapsed: any = {}; volumeCollapsed: any = {};
tabs = ['General', 'Fix Match', 'Cover Image', 'Info']; tabs = ['General', 'Cover Image', 'Info'];
active = this.tabs[0]; active = this.tabs[0];
editSeriesForm!: FormGroup; editSeriesForm!: FormGroup;
libraryName: string | undefined = undefined; libraryName: string | undefined = undefined;

View File

@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { asyncScheduler, Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators'; import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
import { Download } from 'src/app/shared/_models/download'; import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service'; import { DownloadService } from 'src/app/shared/_services/download.service';
import { UtilityService } from 'src/app/shared/_services/utility.service'; import { UtilityService } from 'src/app/shared/_services/utility.service';
@ -96,7 +96,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
if (!wantToDownload) { return; } if (!wantToDownload) { return; }
this.downloadInProgress = true; this.downloadInProgress = true;
this.download$ = this.downloadService.downloadVolume(volume).pipe( this.download$ = this.downloadService.downloadVolume(volume).pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => { takeWhile(val => {
return val.state != 'DONE'; return val.state != 'DONE';
}), }),
@ -112,7 +111,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
if (!wantToDownload) { return; } if (!wantToDownload) { return; }
this.downloadInProgress = true; this.downloadInProgress = true;
this.download$ = this.downloadService.downloadChapter(chapter).pipe( this.download$ = this.downloadService.downloadChapter(chapter).pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => { takeWhile(val => {
return val.state != 'DONE'; return val.state != 'DONE';
}), }),
@ -128,7 +126,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
if (!wantToDownload) { return; } if (!wantToDownload) { return; }
this.downloadInProgress = true; this.downloadInProgress = true;
this.download$ = this.downloadService.downloadSeries(series).pipe( this.download$ = this.downloadService.downloadSeries(series).pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => { takeWhile(val => {
return val.state != 'DONE'; return val.state != 'DONE';
}), }),

View File

@ -54,24 +54,18 @@
</div> </div>
</ul> </ul>
<!-- <div class="back-to-top">
<button class="btn btn-icon" (click)="navService?.toggleDarkMode()">
<i class="fa {{(navService?.darkMode$ | async) ? 'fa-moon' : 'fa-sun'}}" style="color: white" aria-hidden="true"></i>
<span class="sr-only">Dark mode Toggle. Current value {{(navService?.darkMode$ | async) ? 'On' : 'Off'}}</span>
</button>
</div> -->
<div class="back-to-top"> <div class="back-to-top">
<button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded"> <button class="btn btn-icon scroll-to-top" (click)="scrollToTop()" *ngIf="backToTopNeeded">
<i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i> <i class="fa fa-angle-double-up" style="color: white" aria-hidden="true"></i>
<span class="sr-only">Scroll to Top</span> <span class="sr-only">Scroll to Top</span>
</button> </button>
</div> </div>
<!-- TODO: Put SignalR notification button dropdown here. --> <!-- TODO: Put SignalR notification button dropdown here. -->
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown> <div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>{{user.username | titlecase}}</button> <button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>{{user.username | titlecase}}</button>
<div ngbDropdownMenu > <div ngbDropdownMenu >
<button ngbDropdownItem routerLink="/preferences">User Settings</button> <button ngbDropdownItem routerLink="/preferences/">User Settings</button>
<button ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</button> <button ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</button>
<button ngbDropdownItem (click)="logout()">Logout</button> <button ngbDropdownItem (click)="logout()">Logout</button>
</div> </div>

View File

@ -3,8 +3,7 @@ import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { asyncScheduler } from 'rxjs'; import { finalize, take, takeWhile } from 'rxjs/operators';
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component'; import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component'; import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config'; import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
@ -456,7 +455,6 @@ export class SeriesDetailComponent implements OnInit {
if (!wantToDownload) { return; } if (!wantToDownload) { return; }
this.downloadInProgress = true; this.downloadInProgress = true;
this.downloadService.downloadSeries(this.series).pipe( this.downloadService.downloadSeries(this.series).pipe(
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
takeWhile(val => { takeWhile(val => {
return val.state != 'DONE'; return val.state != 'DONE';
}), }),

View File

@ -1,17 +1,18 @@
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { Series } from 'src/app/_models/series'; import { Series } from 'src/app/_models/series';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { ConfirmService } from '../confirm.service'; import { ConfirmService } from '../confirm.service';
import { saveAs } from 'file-saver';
import { Chapter } from 'src/app/_models/chapter'; import { Chapter } from 'src/app/_models/chapter';
import { Volume } from 'src/app/_models/volume'; import { Volume } from 'src/app/_models/volume';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs'; import { asyncScheduler, Observable } from 'rxjs';
import { SAVER, Saver } from '../_providers/saver.provider'; import { SAVER, Saver } from '../_providers/saver.provider';
import { download, Download } from '../_models/download'; import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/page-bookmark'; import { PageBookmark } from 'src/app/_models/page-bookmark';
import { debounceTime } from 'rxjs/operators'; import { throttleTime } from 'rxjs/operators';
const DEBOUNCE_TIME = 100;
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -42,7 +43,7 @@ export class DownloadService {
downloadLogs() { downloadLogs() {
return this.httpClient.get(this.baseUrl + 'server/logs', return this.httpClient.get(this.baseUrl + 'server/logs',
{observe: 'events', responseType: 'blob', reportProgress: true} {observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => { ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
this.save(blob, filename) this.save(blob, filename)
})); }));
@ -51,7 +52,7 @@ export class DownloadService {
downloadSeries(series: Series) { downloadSeries(series: Series) {
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id, return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
{observe: 'events', responseType: 'blob', reportProgress: true} {observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => { ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
this.save(blob, filename) this.save(blob, filename)
})); }));
} }
@ -59,7 +60,7 @@ export class DownloadService {
downloadChapter(chapter: Chapter) { downloadChapter(chapter: Chapter) {
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id, return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
{observe: 'events', responseType: 'blob', reportProgress: true} {observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => { //NOTE: DO I need debounceTime since I have throttleTime()? ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => { //NOTE: DO I need debounceTime since I have throttleTime()?
this.save(blob, filename) this.save(blob, filename)
})); }));
} }
@ -67,7 +68,7 @@ export class DownloadService {
downloadVolume(volume: Volume): Observable<Download> { downloadVolume(volume: Volume): Observable<Download> {
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id, return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
{observe: 'events', responseType: 'blob', reportProgress: true} {observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => { ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
this.save(blob, filename) this.save(blob, filename)
})); }));
} }
@ -79,7 +80,7 @@ export class DownloadService {
downloadBookmarks(bookmarks: PageBookmark[]) { downloadBookmarks(bookmarks: PageBookmark[]) {
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
{observe: 'events', responseType: 'blob', reportProgress: true} {observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(debounceTime(300), download((blob, filename) => { ).pipe(throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), download((blob, filename) => {
this.save(blob, filename) this.save(blob, filename)
})); }));
} }

View File

@ -35,11 +35,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
CommonModule, CommonModule,
RouterModule, RouterModule,
ReactiveFormsModule, ReactiveFormsModule,
//NgbDropdownModule,
//NgbProgressbarModule,
//NgbTooltipModule,
NgbCollapseModule, NgbCollapseModule,
//LazyLoadImageModule,
NgCircleProgressModule.forRoot(), NgCircleProgressModule.forRoot(),
], ],
exports: [ exports: [
@ -55,6 +51,6 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
TagBadgeComponent, TagBadgeComponent,
CircularLoaderComponent, CircularLoaderComponent,
], ],
providers: [{provide: SAVER, useFactory: getSaver}] //providers: [{provide: SAVER, useFactory: getSaver}]
}) })
export class SharedModule { } export class SharedModule { }

View File

@ -1,192 +0,0 @@
<div class="container">
<h2>User Preferences</h2>
<p>
These are global settings that are bound to your account.
</p>
<ngb-accordion [closeOthers]="true" activeIds="site-panel">
<ngb-panel id="site-panel" title="Site">
<ng-template ngbPanelContent>
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<div class="form-group">
<label id="site-dark-mode-label" aria-describedby="site-heading">Dark Mode</label>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="site-dark-mode" formControlName="siteDarkMode" class="custom-control-input" [value]="true" aria-labelledby="site-dark-mode-label">
<label class="custom-control-label" for="site-dark-mode">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="site-not-dark-mode" formControlName="siteDarkMode" class="custom-control-input" [value]="false" aria-labelledby="site-dark-mode-label">
<label class="custom-control-label" for="site-not-dark-mode">False</label>
</div>
</div>
</div>
<div class="float-right mb-3">
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</ng-template>
</ngb-panel>
<ngb-panel id="reading-panel" title="Reading">
<ng-template ngbPanelContent>
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<h3 id="manga-header">Manga</h3>
<div class="form-group">
<label for="settings-reading-direction">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #readingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="sr-only" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-control" aria-describedby="manga-header" formControlName="readingDirection" id="settings-reading-direction">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-scaling-option">Scaling Options</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How to scale the image to your screen.</ng-template>
<span class="sr-only" id="settings-scaling-option-help">How to scale the image to your screen.</span>
<select class="form-control" aria-describedby="manga-header" formControlName="scalingOption" id="settings-scaling-option">
<option *ngFor="let opt of scalingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-pagesplit-option">Page Splitting</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
<ng-template #pageSplitOptionTooltip>How to split a full width image (ie both left and right images are combined)</ng-template>
<span class="sr-only" id="settings-pagesplit-option-help">How to split a full width image (ie both left and right images are combined)</span>
<select class="form-control" aria-describedby="manga-header" formControlName="pageSplitOption" id="settings-pagesplit-option">
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-readingmode-option">Reading Mode</label>
<select class="form-control" aria-describedby="manga-header" formControlName="readerMode" id="settings-readingmode-option">
<option *ngFor="let opt of readingModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label id="auto-close-label">Auto Close Menu</label>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="auto-close" formControlName="autoCloseMenu" class="custom-control-input" [value]="true" aria-labelledby="auto-close-label">
<label class="custom-control-label" for="auto-close">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="not-auto-close" formControlName="autoCloseMenu" class="custom-control-input" [value]="false" aria-labelledby="auto-close-label">
<label class="custom-control-label" for="not-auto-close">False</label>
</div>
</div>
</div>
<hr>
<h3>Books</h3>
<div class="form-group">
<label id="dark-mode-label">Dark Mode</label>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="dark-mode" formControlName="bookReaderDarkMode" class="custom-control-input" [value]="true" aria-labelledby="dark-mode-label">
<label class="custom-control-label" for="dark-mode">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="not-dark-mode" formControlName="bookReaderDarkMode" class="custom-control-input" [value]="false" aria-labelledby="dark-mode-label">
<label class="custom-control-label" for="not-dark-mode">False</label>
</div>
</div>
</div>
<div class="form-group">
<label for="settings-book-reading-direction">Book Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReadingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReadingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="sr-only" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-control" aria-describedby="settings-reading-direction-help" formControlName="bookReaderReadingDirection">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label id="taptopaginate-label">Tap to Paginate</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tapToPaginateOptionTooltip" role="button" tabindex="0"></i>
<ng-template #tapToPaginateOptionTooltip>Should the sides of the book reader screen allow tapping on it to move to prev/next page</ng-template>
<span class="sr-only" id="settings-taptopaginate-option-help">Should the sides of the book reader screen allow tapping on it to move to prev/next page</span>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="taptopaginate" formControlName="bookReaderTapToPaginate" class="custom-control-input" [value]="true" aria-labelledby="taptopaginate-label">
<label class="custom-control-label" for="taptopaginate">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="not-taptopaginate" formControlName="bookReaderTapToPaginate" class="custom-control-input" [value]="false" aria-labelledby="taptopaginate-label">
<label class="custom-control-label" for="not-taptopaginate">False</label>
</div>
</div>
</div>
<div class="form-group">
<label for="settings-fontfamily-option">Font Family</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
<ng-template #fontFamilyOptionTooltip>Font familty to load up. Default will load the book's default font</ng-template>
<span class="sr-only" id="settings-fontfamily-option-help">Font familty to load up. Default will load the book's default font</span>
<select class="form-control" aria-describedby="settings-fontfamily-option-help" formControlName="bookReaderFontFamily">
<option *ngFor="let opt of fontFamilies" [value]="opt">{{opt | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label id="font-size">Font Size</label>
<ngx-slider [options]="bookReaderFontSizeOptions" formControlName="bookReaderFontSize" aria-labelledby="font-size"></ngx-slider>
</div>
<div class="form-group">
<label>Line Height</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>How much spacing between the lines of the book</ng-template>
<span class="sr-only" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
<ngx-slider [options]="bookReaderLineSpacingOptions" formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help"></ngx-slider>
</div>
<div class="form-group">
<label>Margin</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
<span class="sr-only" id="settings-bookmargin-option-help">How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</span>
<ngx-slider [options]="bookReaderMarginOptions" formControlName="bookReaderMargin" aria-describedby="bookmargin"></ngx-slider>
</div>
<div class="float-right mb-3">
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</ng-template>
</ngb-panel>
<ngb-panel id="password-panel" title="Password">
<ng-template ngbPanelContent>
<p>Change your Password</p>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
</div>
<form [formGroup]="passwordChangeForm">
<div class="form-group">
<label for="new-password">New Password</label>
<input class="form-control" type="password" id="new-password" formControlName="password" required>
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="password?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations" required>
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="!passwordsMatch">
Passwords must match
</div>
<div *ngIf="confirmPassword?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="float-right mb-3">
<button type="button" class="btn btn-secondary mr-2" (click)="resetPasswordForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
</div>
</form>
</ng-template>
</ngb-panel>
</ngb-accordion>
</div>

View File

@ -0,0 +1,42 @@
<p *ngIf="series.length === 0 && !loadingBookmarks">
There are no bookmarks. Try creating <a href="https://wiki.kavitareader.com/guides/webreader/bookmarks" target="_blank">one&nbsp;<i class="fa fa-external-link-alt" aria-hidden="true"></i></a>.
</p>
<ul class="list-group">
<li *ngFor="let series of series" class="list-group-item">
<div>
<h4>
<a href="/library/{{series.libraryId}}/series/{{series.id}}" >{{series.name | titlecase}}</a>
&nbsp;<span class="badge badge-secondary badge-pill">{{getBookmarkPages(series.id)}}</span>
<div class="float-right">
<button class="btn btn-danger mr-2 btn-sm" (click)="clearBookmarks(series)" [disabled]="clearingSeries[series.id]" placement="top" ngbTooltip="Clear Bookmarks" attr.aria-label="Clear Bookmarks">
<ng-container *ngIf="clearingSeries[series.id]; else notClearing">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</ng-container>
<ng-template #notClearing>
<i class="fa fa-trash-alt" aria-hidden="true"></i>
</ng-template>
</button>
<button class="btn btn-secondary mr-2 btn-sm" (click)="downloadBookmarks(series)" [disabled]="downloadingSeries[series.id]" placement="top" ngbTooltip="Download Bookmarks" attr.aria-label="Download Bookmarks">
<ng-container *ngIf="downloadingSeries[series.id]; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Downloading...</span>
</ng-container>
<ng-template #notDownloading>
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</ng-template>
</button>
<button class="btn btn-primary mr-2 btn-sm" (click)="viewBookmarks(series)" placement="top" ngbTooltip="View Bookmarks" attr.aria-label="View Bookmarks">
<i class="fa fa-pen" title="View Bookmarks"></i>
</button>
</div>
</h4>
</div>
</li>
<li *ngIf="loadingBookmarks" class="list-group-item">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
</ul>

View File

@ -0,0 +1,96 @@
import { Component, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take, takeWhile, finalize } from 'rxjs/operators';
import { BookmarksModalComponent } from 'src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { PageBookmark } from 'src/app/_models/page-bookmark';
import { Series } from 'src/app/_models/series';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-series-bookmarks',
templateUrl: './series-bookmarks.component.html',
styleUrls: ['./series-bookmarks.component.scss']
})
export class SeriesBookmarksComponent implements OnInit {
bookmarks: Array<PageBookmark> = [];
series: Array<Series> = [];
loadingBookmarks: boolean = false;
seriesIds: {[id: number]: number} = {};
downloadingSeries: {[id: number]: boolean} = {};
clearingSeries: {[id: number]: boolean} = {};
constructor(private readerService: ReaderService, private seriesService: SeriesService,
private modalService: NgbModal, private downloadService: DownloadService, private toastr: ToastrService,
private confirmService: ConfirmService) { }
ngOnInit(): void {
this.loadBookmarks();
}
loadBookmarks() {
this.loadingBookmarks = true;
this.readerService.getAllBookmarks().pipe(take(1)).subscribe(bookmarks => {
this.bookmarks = bookmarks;
this.seriesIds = {};
this.bookmarks.forEach(bmk => {
if (!this.seriesIds.hasOwnProperty(bmk.seriesId)) {
this.seriesIds[bmk.seriesId] = 1;
} else {
this.seriesIds[bmk.seriesId] += 1;
}
this.downloadingSeries[bmk.seriesId] = false;
this.clearingSeries[bmk.seriesId] = false;
});
const ids = Object.keys(this.seriesIds).map(k => parseInt(k, 10));
this.seriesService.getAllSeriesByIds(ids).subscribe(series => {
this.series = series;
this.loadingBookmarks = false;
});
});
}
viewBookmarks(series: Series) {
const bookmarkModalRef = this.modalService.open(BookmarksModalComponent, { scrollable: true, size: 'lg' });
bookmarkModalRef.componentInstance.series = series;
bookmarkModalRef.closed.pipe(take(1)).subscribe(() => {
this.loadBookmarks();
});
}
async clearBookmarks(series: Series) {
if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for ' + series.name + '? This cannot be undone.')) {
return;
}
this.clearingSeries[series.id] = true;
this.readerService.clearBookmarks(series.id).subscribe(() => {
const index = this.series.indexOf(series);
if (index > -1) {
this.series.splice(index, 1);
}
this.clearingSeries[series.id] = false;
this.toastr.success(series.name + '\'s bookmarks have been removed');
});
}
getBookmarkPages(seriesId: number) {
return this.seriesIds[seriesId];
}
downloadBookmarks(series: Series) {
this.downloadingSeries[series.id] = true;
this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => bmk.seriesId === series.id)).pipe(
takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.downloadingSeries[series.id] = false;
})).subscribe(() => {/* No Operation */});
}
}

View File

@ -0,0 +1,203 @@
<div class="container">
<h2>User Dashboard</h2>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === ''">
<p>
These are global settings that are bound to your account.
</p>
<ngb-accordion [closeOthers]="true" activeIds="site-panel">
<ngb-panel id="site-panel" title="Site">
<ng-template ngbPanelContent>
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<div class="form-group">
<label id="site-dark-mode-label" aria-describedby="site-heading">Dark Mode</label>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="site-dark-mode" formControlName="siteDarkMode" class="custom-control-input" [value]="true" aria-labelledby="site-dark-mode-label">
<label class="custom-control-label" for="site-dark-mode">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="site-not-dark-mode" formControlName="siteDarkMode" class="custom-control-input" [value]="false" aria-labelledby="site-dark-mode-label">
<label class="custom-control-label" for="site-not-dark-mode">False</label>
</div>
</div>
</div>
<div class="float-right mb-3">
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</ng-template>
</ngb-panel>
<ngb-panel id="reading-panel" title="Reading">
<ng-template ngbPanelContent>
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<h3 id="manga-header">Manga</h3>
<div class="form-group">
<label for="settings-reading-direction">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #readingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="sr-only" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-control" aria-describedby="manga-header" formControlName="readingDirection" id="settings-reading-direction">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-scaling-option">Scaling Options</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How to scale the image to your screen.</ng-template>
<span class="sr-only" id="settings-scaling-option-help">How to scale the image to your screen.</span>
<select class="form-control" aria-describedby="manga-header" formControlName="scalingOption" id="settings-scaling-option">
<option *ngFor="let opt of scalingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-pagesplit-option">Page Splitting</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
<ng-template #pageSplitOptionTooltip>How to split a full width image (ie both left and right images are combined)</ng-template>
<span class="sr-only" id="settings-pagesplit-option-help">How to split a full width image (ie both left and right images are combined)</span>
<select class="form-control" aria-describedby="manga-header" formControlName="pageSplitOption" id="settings-pagesplit-option">
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label for="settings-readingmode-option">Reading Mode</label>
<select class="form-control" aria-describedby="manga-header" formControlName="readerMode" id="settings-readingmode-option">
<option *ngFor="let opt of readingModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label id="auto-close-label">Auto Close Menu</label>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="auto-close" formControlName="autoCloseMenu" class="custom-control-input" [value]="true" aria-labelledby="auto-close-label">
<label class="custom-control-label" for="auto-close">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="not-auto-close" formControlName="autoCloseMenu" class="custom-control-input" [value]="false" aria-labelledby="auto-close-label">
<label class="custom-control-label" for="not-auto-close">False</label>
</div>
</div>
</div>
<hr>
<h3>Books</h3>
<div class="form-group">
<label id="dark-mode-label">Dark Mode</label>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="dark-mode" formControlName="bookReaderDarkMode" class="custom-control-input" [value]="true" aria-labelledby="dark-mode-label">
<label class="custom-control-label" for="dark-mode">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="not-dark-mode" formControlName="bookReaderDarkMode" class="custom-control-input" [value]="false" aria-labelledby="dark-mode-label">
<label class="custom-control-label" for="not-dark-mode">False</label>
</div>
</div>
</div>
<div class="form-group">
<label for="settings-book-reading-direction">Book Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReadingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReadingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="sr-only" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-control" aria-describedby="settings-reading-direction-help" formControlName="bookReaderReadingDirection">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label id="taptopaginate-label">Tap to Paginate</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tapToPaginateOptionTooltip" role="button" tabindex="0"></i>
<ng-template #tapToPaginateOptionTooltip>Should the sides of the book reader screen allow tapping on it to move to prev/next page</ng-template>
<span class="sr-only" id="settings-taptopaginate-option-help">Should the sides of the book reader screen allow tapping on it to move to prev/next page</span>
<div class="form-group">
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="taptopaginate" formControlName="bookReaderTapToPaginate" class="custom-control-input" [value]="true" aria-labelledby="taptopaginate-label">
<label class="custom-control-label" for="taptopaginate">True</label>
</div>
<div class="custom-control custom-radio custom-control-inline">
<input type="radio" id="not-taptopaginate" formControlName="bookReaderTapToPaginate" class="custom-control-input" [value]="false" aria-labelledby="taptopaginate-label">
<label class="custom-control-label" for="not-taptopaginate">False</label>
</div>
</div>
</div>
<div class="form-group">
<label for="settings-fontfamily-option">Font Family</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
<ng-template #fontFamilyOptionTooltip>Font familty to load up. Default will load the book's default font</ng-template>
<span class="sr-only" id="settings-fontfamily-option-help">Font familty to load up. Default will load the book's default font</span>
<select class="form-control" aria-describedby="settings-fontfamily-option-help" formControlName="bookReaderFontFamily">
<option *ngFor="let opt of fontFamilies" [value]="opt">{{opt | titlecase}}</option>
</select>
</div>
<div class="form-group">
<label id="font-size">Font Size</label>
<ngx-slider [options]="bookReaderFontSizeOptions" formControlName="bookReaderFontSize" aria-labelledby="font-size"></ngx-slider>
</div>
<div class="form-group">
<label>Line Height</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>How much spacing between the lines of the book</ng-template>
<span class="sr-only" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
<ngx-slider [options]="bookReaderLineSpacingOptions" formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help"></ngx-slider>
</div>
<div class="form-group">
<label>Margin</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
<span class="sr-only" id="settings-bookmargin-option-help">How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</span>
<ngx-slider [options]="bookReaderMarginOptions" formControlName="bookReaderMargin" aria-describedby="bookmargin"></ngx-slider>
</div>
<div class="float-right mb-3">
<button type="button" class="btn btn-secondary mr-2" (click)="resetForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="save()" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</form>
</ng-template>
</ngb-panel>
<ngb-panel id="password-panel" title="Password">
<ng-template ngbPanelContent>
<p>Change your Password</p>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
</div>
<form [formGroup]="passwordChangeForm">
<div class="form-group">
<label for="new-password">New Password</label>
<input class="form-control" type="password" id="new-password" formControlName="password" required>
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="password?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations" required>
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="!passwordsMatch">
Passwords must match
</div>
<div *ngIf="confirmPassword?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="float-right mb-3">
<button type="button" class="btn btn-secondary mr-2" (click)="resetPasswordForm()">Reset</button>
<button type="submit" class="btn btn-primary" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
</div>
</form>
</ng-template>
</ngb-panel>
</ngb-accordion>
</ng-container>
<ng-container *ngIf="tab.fragment === 'bookmarks'">
<app-series-bookmarks></app-series-bookmarks>
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>

View File

@ -2,13 +2,14 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { pageSplitOptions, Preferences, readingDirections, scalingOptions, readingModes } from '../_models/preferences/preferences';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { Options } from '@angular-slider/ngx-slider'; import { Options } from '@angular-slider/ngx-slider';
import { BookService } from '../book-reader/book.service';
import { NavService } from '../_services/nav.service';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { BookService } from 'src/app/book-reader/book.service';
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences } from 'src/app/_models/preferences/preferences';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
import { ActivatedRoute } from '@angular/router';
@Component({ @Component({
selector: 'app-user-preferences', selector: 'app-user-preferences',
@ -48,8 +49,25 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
}; };
fontFamilies: Array<string> = []; fontFamilies: Array<string> = [];
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService, private navService: NavService, private titleService: Title) { //tabs = ['Preferences', 'Bookmarks'];
tabs: Array<{title: string, fragment: string}> = [
{title: 'Preferences', fragment: ''},
{title: 'Bookmarks', fragment: 'bookmarks'},
];
active = this.tabs[0];
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private navService: NavService, private titleService: Title, private route: ActivatedRoute) {
this.fontFamilies = this.bookService.getFontFamilies(); this.fontFamilies = this.bookService.getFontFamilies();
this.route.fragment.subscribe(frag => {
const tab = this.tabs.filter(item => item.fragment === frag);
if (tab.length > 0) {
this.active = tab[0];
} else {
this.active = this.tabs[0]; // Default to first tab
}
});
} }
ngOnInit(): void { ngOnInit(): void {
@ -159,5 +177,4 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.resetPasswordErrors = err; this.resetPasswordErrors = err;
})); }));
} }
} }

View File

@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { AuthGuard } from '../_guards/auth.guard';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
const routes: Routes = [
{path: '**', component: UserPreferencesComponent, pathMatch: 'full'},
{
runGuardsAndResolvers: 'always',
canActivate: [AuthGuard],
children: [
{path: '', component: UserPreferencesComponent, pathMatch: 'full'},
]
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class UserSettingsRoutingModule { }

View File

@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesBookmarksComponent } from './series-bookmarks/series-bookmarks.component';
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
import { NgbAccordionModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { NgxSliderModule } from '@angular-slider/ngx-slider';
import { UserSettingsRoutingModule } from './user-settings-routing.module';
@NgModule({
declarations: [
SeriesBookmarksComponent,
UserPreferencesComponent
],
imports: [
CommonModule,
ReactiveFormsModule,
NgbAccordionModule,
NgbNavModule,
NgbTooltipModule,
NgxSliderModule,
UserSettingsRoutingModule,
]
})
export class UserSettingsModule { }

View File

@ -20,7 +20,10 @@ $dark-item-accent-bg: #292d32;
} }
a, .btn-link { a, .btn-link {
color: #4ac694; color: $dark-hover-color;
> i {
color: $dark-hover-color !important;
}
} }
a.btn { a.btn {