mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
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:
parent
623e555633
commit
68bb5ed5a8
@ -11,6 +11,7 @@ using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers
|
||||
{
|
||||
@ -22,16 +23,18 @@ namespace API.Controllers
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderController> _logger;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork)
|
||||
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger<ReaderController> logger)
|
||||
{
|
||||
_directoryService = directoryService;
|
||||
_cacheService = cacheService;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -350,19 +353,31 @@ namespace API.Controllers
|
||||
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>
|
||||
/// Removes all bookmarks for all chapters linked to a Series
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[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());
|
||||
if (user.Bookmarks == null) return Ok("Nothing to remove");
|
||||
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);
|
||||
|
||||
if (await _unitOfWork.CommitAsync())
|
||||
@ -370,8 +385,9 @@ namespace API.Controllers
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when trying to clear bookmarks");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
|
@ -12,6 +12,7 @@ using API.Interfaces;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers
|
||||
@ -154,8 +155,8 @@ namespace API.Controllers
|
||||
}
|
||||
series.Name = updateSeries.Name.Trim();
|
||||
series.LocalizedName = updateSeries.LocalizedName.Trim();
|
||||
series.SortName = updateSeries.SortName.Trim();
|
||||
series.Summary = updateSeries.Summary.Trim();
|
||||
series.SortName = updateSeries.SortName?.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;
|
||||
if (!updateSeries.CoverImageLocked)
|
||||
@ -296,8 +297,9 @@ namespace API.Controllers
|
||||
return Ok("Successfully updated");
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when updating metadata");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
@ -327,6 +329,19 @@ namespace API.Controllers
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
7
API/DTOs/RemoveBookmarkForSeriesDto.cs
Normal file
7
API/DTOs/RemoveBookmarkForSeriesDto.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class RemoveBookmarkForSeriesDto
|
||||
{
|
||||
public int SeriesId { get; init; }
|
||||
}
|
||||
}
|
9
API/DTOs/SeriesByIdsDto.cs
Normal file
9
API/DTOs/SeriesByIdsDto.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs
|
||||
{
|
||||
public class SeriesByIdsDto
|
||||
{
|
||||
public int[] SeriesIds { get; init; }
|
||||
}
|
||||
}
|
@ -66,9 +66,9 @@ namespace API.Data
|
||||
return new CollectionTag()
|
||||
{
|
||||
Id = id,
|
||||
NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(),
|
||||
Title = title,
|
||||
Summary = summary,
|
||||
NormalizedTitle = API.Parser.Parser.Normalize(title?.Trim()).ToUpper(),
|
||||
Title = title?.Trim(),
|
||||
Summary = summary?.Trim(),
|
||||
Promoted = promoted
|
||||
};
|
||||
}
|
||||
|
@ -443,5 +443,21 @@ namespace API.Data
|
||||
.AsNoTracking()
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -106,6 +106,17 @@ namespace API.Data
|
||||
.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()
|
||||
{
|
||||
return await _context.Users
|
||||
|
@ -9,7 +9,7 @@ namespace API.Entities
|
||||
/// Represents a user entered field that is used as a tagging and grouping mechanism
|
||||
/// </summary>
|
||||
[Index(nameof(Id), nameof(Promoted), IsUnique = true)]
|
||||
public class CollectionTag : IHasConcurrencyToken
|
||||
public class CollectionTag
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@ -42,12 +42,14 @@ namespace API.Entities
|
||||
|
||||
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; }
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
[ConcurrencyCheck]
|
||||
/// <summary>
|
||||
/// Not Used due to not using concurrency update
|
||||
/// </summary>
|
||||
public uint RowVersion { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>
|
||||
/// Not Used due to not using concurrency update
|
||||
/// </summary>
|
||||
public void OnSavingChanges()
|
||||
{
|
||||
RowVersion++;
|
||||
|
@ -63,5 +63,6 @@ namespace API.Interfaces
|
||||
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
|
||||
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
|
||||
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
|
||||
Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId);
|
||||
}
|
||||
}
|
||||
|
@ -19,5 +19,6 @@ namespace API.Interfaces
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
|
||||
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
|
||||
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
|
||||
}
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ namespace API.Services
|
||||
using var img = Image.NewFromFile(path);
|
||||
using var stream = new MemoryStream();
|
||||
img.JpegsaveStream(stream);
|
||||
stream.Position = 0;
|
||||
return stream.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -28,6 +28,10 @@ export class ReaderService {
|
||||
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) {
|
||||
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-bookmarks?chapterId=' + chapterId);
|
||||
}
|
||||
|
@ -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) {
|
||||
return this.httpClient.get<Series>(this.baseUrl + 'series/' + seriesId);
|
||||
}
|
||||
|
@ -20,6 +20,6 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
|
||||
</div>
|
@ -1,8 +1,7 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler } from 'rxjs';
|
||||
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { finalize, take, takeWhile } from 'rxjs/operators';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { ServerService } from 'src/app/_services/server.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
@ -92,7 +91,6 @@ export class ManageSystemComponent implements OnInit {
|
||||
downloadLogs() {
|
||||
this.downloadLogsInProgress = true;
|
||||
this.downloadService.downloadLogs().pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
|
@ -7,7 +7,6 @@ import { NotConnectedComponent } from './not-connected/not-connected.component';
|
||||
import { SeriesDetailComponent } from './series-detail/series-detail.component';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
import { UserLoginComponent } from './user-login/user-login.component';
|
||||
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
|
||||
import { AuthGuard } from './_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
||||
@ -24,6 +23,10 @@ const routes: Routes = [
|
||||
path: 'collections',
|
||||
loadChildren: () => import('./collections/collections.module').then(m => m.CollectionsModule)
|
||||
},
|
||||
{
|
||||
path: 'preferences',
|
||||
loadChildren: () => import('./user-settings/user-settings.module').then(m => m.UserSettingsModule)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
runGuardsAndResolvers: 'always',
|
||||
@ -49,7 +52,6 @@ const routes: Routes = [
|
||||
{path: 'library', component: LibraryComponent},
|
||||
{path: 'recently-added', component: RecentlyAddedComponent},
|
||||
{path: 'in-progress', component: InProgressComponent},
|
||||
{path: 'preferences', component: UserPreferencesComponent},
|
||||
]
|
||||
},
|
||||
{path: 'login', component: UserLoginComponent},
|
||||
|
@ -18,7 +18,6 @@ import { SharedModule } from './shared/shared.module';
|
||||
import { LibraryDetailComponent } from './library-detail/library-detail.component';
|
||||
import { SeriesDetailComponent } from './series-detail/series-detail.component';
|
||||
import { NotConnectedComponent } from './not-connected/not-connected.component';
|
||||
import { UserPreferencesComponent } from './user-preferences/user-preferences.component';
|
||||
import { AutocompleteLibModule } from 'angular-ng-autocomplete';
|
||||
import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component';
|
||||
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 { CardsModule } from './cards/cards.module';
|
||||
import { CollectionsModule } from './collections/collections.module';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { SAVER, getSaver } from './shared/_providers/saver.provider';
|
||||
|
||||
let sentryProviders: any[] = [];
|
||||
|
||||
@ -93,7 +94,6 @@ if (environment.production) {
|
||||
LibraryDetailComponent,
|
||||
SeriesDetailComponent,
|
||||
NotConnectedComponent, // Move into ExtrasModule
|
||||
UserPreferencesComponent, // Move into SettingsModule
|
||||
ReviewSeriesModalComponent,
|
||||
PersonBadgeComponent,
|
||||
RecentlyAddedComponent,
|
||||
@ -109,11 +109,11 @@ if (environment.production) {
|
||||
|
||||
NgbDropdownModule, // Nav
|
||||
AutocompleteLibModule, // Nav
|
||||
NgbTooltipModule, // Shared & SettingsModule
|
||||
//NgbTooltipModule, // Shared & SettingsModule
|
||||
NgbRatingModule, // Series Detail
|
||||
NgbNavModule,
|
||||
NgbAccordionModule, // User Preferences
|
||||
NgxSliderModule, // User Preference
|
||||
//NgbAccordionModule, // User Preferences
|
||||
//NgxSliderModule, // User Preference
|
||||
NgbPaginationModule,
|
||||
|
||||
|
||||
@ -135,6 +135,7 @@ if (environment.production) {
|
||||
{provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true},
|
||||
{provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true},
|
||||
Title,
|
||||
{provide: SAVER, useFactory: getSaver},
|
||||
...sentryProviders,
|
||||
],
|
||||
entryComponents: [],
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler } from 'rxjs';
|
||||
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { finalize, take, takeWhile } from 'rxjs/operators';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
@ -55,7 +54,6 @@ export class BookmarksModalComponent implements OnInit {
|
||||
downloadBookmarks() {
|
||||
this.isDownloading = true;
|
||||
this.downloadService.downloadBookmarks(this.bookmarks).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
|
@ -82,12 +82,6 @@
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[1]">
|
||||
<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>
|
||||
<p class="alert alert-primary" role="alert">
|
||||
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>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="tabs[3]">
|
||||
<a ngbNavLink>{{tabs[3]}}</a>
|
||||
<li [ngbNavItem]="tabs[2]">
|
||||
<a ngbNavLink>{{tabs[2]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<h4>Information</h4>
|
||||
<div class="row no-gutters mb-2">
|
||||
|
@ -28,7 +28,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
|
||||
isCollapsed = true;
|
||||
volumeCollapsed: any = {};
|
||||
tabs = ['General', 'Fix Match', 'Cover Image', 'Info'];
|
||||
tabs = ['General', 'Cover Image', 'Info'];
|
||||
active = this.tabs[0];
|
||||
editSeriesForm!: FormGroup;
|
||||
libraryName: string | undefined = undefined;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler, Observable, Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { Download } from 'src/app/shared/_models/download';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
@ -96,7 +96,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadVolume(volume).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
@ -112,7 +111,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
@ -128,7 +126,6 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.download$ = this.downloadService.downloadSeries(series).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
|
@ -54,24 +54,18 @@
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
<span class="sr-only">Scroll to Top</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>{{user.username | titlecase}}</button>
|
||||
<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 (click)="logout()">Logout</button>
|
||||
</div>
|
||||
|
@ -3,8 +3,7 @@ import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { asyncScheduler } from 'rxjs';
|
||||
import { finalize, take, takeWhile, throttleTime } from 'rxjs/operators';
|
||||
import { finalize, take, takeWhile } from 'rxjs/operators';
|
||||
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
|
||||
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
|
||||
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
|
||||
@ -456,7 +455,6 @@ export class SeriesDetailComponent implements OnInit {
|
||||
if (!wantToDownload) { return; }
|
||||
this.downloadInProgress = true;
|
||||
this.downloadService.downloadSeries(this.series).pipe(
|
||||
throttleTime(100, asyncScheduler, { leading: true, trailing: true }),
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
|
@ -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 { Series } from 'src/app/_models/series';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ConfirmService } from '../confirm.service';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Observable } from 'rxjs';
|
||||
import { asyncScheduler, Observable } from 'rxjs';
|
||||
import { SAVER, Saver } from '../_providers/saver.provider';
|
||||
import { download, Download } from '../_models/download';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { debounceTime } from 'rxjs/operators';
|
||||
import { throttleTime } from 'rxjs/operators';
|
||||
|
||||
const DEBOUNCE_TIME = 100;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -42,7 +43,7 @@ export class DownloadService {
|
||||
downloadLogs() {
|
||||
return this.httpClient.get(this.baseUrl + 'server/logs',
|
||||
{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)
|
||||
}));
|
||||
|
||||
@ -51,7 +52,7 @@ export class DownloadService {
|
||||
downloadSeries(series: Series) {
|
||||
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
|
||||
{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)
|
||||
}));
|
||||
}
|
||||
@ -59,7 +60,7 @@ export class DownloadService {
|
||||
downloadChapter(chapter: Chapter) {
|
||||
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
|
||||
{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)
|
||||
}));
|
||||
}
|
||||
@ -67,7 +68,7 @@ export class DownloadService {
|
||||
downloadVolume(volume: Volume): Observable<Download> {
|
||||
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
|
||||
{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)
|
||||
}));
|
||||
}
|
||||
@ -79,7 +80,7 @@ export class DownloadService {
|
||||
downloadBookmarks(bookmarks: PageBookmark[]) {
|
||||
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks},
|
||||
{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)
|
||||
}));
|
||||
}
|
||||
|
@ -35,11 +35,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ReactiveFormsModule,
|
||||
//NgbDropdownModule,
|
||||
//NgbProgressbarModule,
|
||||
//NgbTooltipModule,
|
||||
NgbCollapseModule,
|
||||
//LazyLoadImageModule,
|
||||
NgCircleProgressModule.forRoot(),
|
||||
],
|
||||
exports: [
|
||||
@ -55,6 +51,6 @@ import { NgCircleProgressModule } from 'ng-circle-progress';
|
||||
TagBadgeComponent,
|
||||
CircularLoaderComponent,
|
||||
],
|
||||
providers: [{provide: SAVER, useFactory: getSaver}]
|
||||
//providers: [{provide: SAVER, useFactory: getSaver}]
|
||||
})
|
||||
export class SharedModule { }
|
||||
|
@ -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> <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> <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> <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> <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> <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> <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> <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> <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>
|
@ -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 <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>
|
||||
<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>
|
||||
|
@ -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 */});
|
||||
}
|
||||
}
|
@ -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> <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> <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> <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> <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> <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> <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> <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> <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>
|
@ -2,13 +2,14 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
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 { BookService } from '../book-reader/book.service';
|
||||
import { NavService } from '../_services/nav.service';
|
||||
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({
|
||||
selector: 'app-user-preferences',
|
||||
@ -48,8 +49,25 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
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.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 {
|
||||
@ -159,5 +177,4 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.resetPasswordErrors = err;
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
22
UI/Web/src/app/user-settings/user-settings-routing.module.ts
Normal file
22
UI/Web/src/app/user-settings/user-settings-routing.module.ts
Normal 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 { }
|
27
UI/Web/src/app/user-settings/user-settings.module.ts
Normal file
27
UI/Web/src/app/user-settings/user-settings.module.ts
Normal 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 { }
|
@ -20,7 +20,10 @@ $dark-item-accent-bg: #292d32;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #4ac694;
|
||||
color: $dark-hover-color;
|
||||
> i {
|
||||
color: $dark-hover-color !important;
|
||||
}
|
||||
}
|
||||
|
||||
a.btn {
|
||||
|
Loading…
x
Reference in New Issue
Block a user