mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-30 21:43:14 -04:00
Readable Bookmarks (#1228)
* Moved bookmarks to it's own page on side nav and integrated actions. * Implemented the ability to read bookmarks in the manga reader. * Removed old bookmark components that aren't needed any longer. * Removed recently added component as we use all-series instead now * Removed bookmark tab from card detail * Fixed scroll to top not working and being missing * When opening the side nav on mobile with metadata filter already open, collapse the filter. * When on mobile viewports, when clicking an item from side nav, collapse it afterwards * Converted most of series detail to use the card detail layout, except storyline which has custom logic * Fixed unit test
This commit is contained in:
parent
62715a9977
commit
9d6843614d
@ -389,7 +389,7 @@ public class BookmarkServiceTests
|
||||
VolumeId = 1
|
||||
}, $"{CacheDirectory}1/0001.jpg");
|
||||
|
||||
var files = await bookmarkService.GetBookmarkFilesById(1, new[] {1});
|
||||
var files = await bookmarkService.GetBookmarkFilesById(new[] {1});
|
||||
var actualFiles = ds.GetFiles(BookmarkDirectory, searchOption: SearchOption.AllDirectories);
|
||||
Assert.Equal(files.Select(API.Parser.Parser.NormalizePath).ToList(), actualFiles.Select(API.Parser.Parser.NormalizePath).ToList());
|
||||
}
|
||||
|
@ -157,7 +157,8 @@ namespace API.Tests.Services
|
||||
filesystem.AddDirectory($"{CacheDirectory}1/");
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cleanupService = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
await ResetDB();
|
||||
var s = DbFactory.Series("Test");
|
||||
@ -240,7 +241,8 @@ namespace API.Tests.Services
|
||||
filesystem.AddFile($"{CacheDirectory}3/003.jpg", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cleanupService = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
cleanupService.CleanupChapters(new []{1, 3});
|
||||
Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories));
|
||||
@ -260,7 +262,8 @@ namespace API.Tests.Services
|
||||
filesystem.AddFile($"{DataDirectory}2.epub", new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cs = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
var c = new Chapter()
|
||||
{
|
||||
@ -311,7 +314,8 @@ namespace API.Tests.Services
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cs = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
// Flatten to prepare for how GetFullPath expects
|
||||
ds.Flatten($"{CacheDirectory}1/");
|
||||
@ -362,7 +366,8 @@ namespace API.Tests.Services
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cs = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
// Flatten to prepare for how GetFullPath expects
|
||||
ds.Flatten($"{CacheDirectory}1/");
|
||||
@ -408,7 +413,8 @@ namespace API.Tests.Services
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cs = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
// Flatten to prepare for how GetFullPath expects
|
||||
ds.Flatten($"{CacheDirectory}1/");
|
||||
@ -460,7 +466,8 @@ namespace API.Tests.Services
|
||||
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
|
||||
var cs = new CacheService(_logger, _unitOfWork, ds,
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(), Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds));
|
||||
new ReadingItemService(Substitute.For<IArchiveService>(),
|
||||
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
|
||||
|
||||
// Flatten to prepare for how GetFullPath expects
|
||||
ds.Flatten($"{CacheDirectory}1/");
|
||||
|
@ -171,7 +171,7 @@ namespace API.Controllers
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
|
||||
|
||||
var files = await _bookmarkService.GetBookmarkFilesById(user.Id, downloadBookmarkDto.Bookmarks.Select(b => b.Id));
|
||||
var files = await _bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id));
|
||||
|
||||
var filename = $"{series.Name} - Bookmarks.zip";
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
|
@ -73,6 +73,41 @@ namespace API.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an image for a given bookmark series. Side effect: This will cache the bookmark images for reading.
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="apiKey">Api key for the user the bookmarks are on</param>
|
||||
/// <param name="page"></param>
|
||||
/// <remarks>We must use api key as bookmarks could be leaked to other users via the API</remarks>
|
||||
/// <returns></returns>
|
||||
[HttpGet("bookmark-image")]
|
||||
public async Task<ActionResult> GetBookmarkImage(int seriesId, string apiKey, int page)
|
||||
{
|
||||
if (page < 0) page = 0;
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||
var totalPages = await _cacheService.CacheBookmarkForSeries(userId, seriesId);
|
||||
if (page > totalPages)
|
||||
{
|
||||
page = totalPages;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = _cacheService.GetCachedBookmarkPagePath(seriesId, page);
|
||||
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
|
||||
var format = Path.GetExtension(path).Replace(".", "");
|
||||
|
||||
Response.AddCacheHeader(path, TimeSpan.FromMinutes(10).Seconds);
|
||||
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_cacheService.CleanupBookmarks(new []{ seriesId });
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading.
|
||||
/// </summary>
|
||||
@ -104,6 +139,29 @@ namespace API.Controllers
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns various information about all bookmark files for a Series. Side effect: This will cache the bookmark images for reading.
|
||||
/// </summary>
|
||||
/// <param name="seriesId">Series Id for all bookmarks</param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("bookmark-info")]
|
||||
public async Task<ActionResult<BookmarkInfoDto>> GetBookmarkInfo(int seriesId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var totalPages = await _cacheService.CacheBookmarkForSeries(user.Id, seriesId);
|
||||
// TODO: Change Includes to None from LinkedSeries branch
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
|
||||
return Ok(new BookmarkInfoDto()
|
||||
{
|
||||
SeriesName = series.Name,
|
||||
SeriesFormat = series.Format,
|
||||
SeriesId = series.Id,
|
||||
LibraryId = series.LibraryId,
|
||||
Pages = totalPages,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("mark-read")]
|
||||
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
|
||||
|
13
API/DTOs/Reader/BookmarkInfoDto.cs
Normal file
13
API/DTOs/Reader/BookmarkInfoDto.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Reader;
|
||||
|
||||
public class BookmarkInfoDto
|
||||
{
|
||||
public string SeriesName { get; set; }
|
||||
public MangaFormat SeriesFormat { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
public LibraryType LibraryType { get; set; }
|
||||
public int Pages { get; set; }
|
||||
}
|
@ -17,7 +17,8 @@ public interface IBookmarkService
|
||||
Task DeleteBookmarkFiles(IEnumerable<AppUserBookmark> bookmarks);
|
||||
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
|
||||
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
|
||||
Task<IEnumerable<string>> GetBookmarkFilesById(int userId, IEnumerable<int> bookmarkIds);
|
||||
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
|
||||
|
||||
}
|
||||
|
||||
public class BookmarkService : IBookmarkService
|
||||
@ -141,7 +142,7 @@ public class BookmarkService : IBookmarkService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> GetBookmarkFilesById(int userId, IEnumerable<int> bookmarkIds)
|
||||
public async Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds)
|
||||
{
|
||||
var bookmarkDirectory =
|
||||
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
|
||||
|
@ -25,9 +25,12 @@ namespace API.Services
|
||||
/// </summary>
|
||||
/// <param name="chapterIds">Volumes that belong to that library. Assume the library might have been deleted before this invocation.</param>
|
||||
void CleanupChapters(IEnumerable<int> chapterIds);
|
||||
void CleanupBookmarks(IEnumerable<int> seriesIds);
|
||||
string GetCachedPagePath(Chapter chapter, int page);
|
||||
string GetCachedBookmarkPagePath(int seriesId, int page);
|
||||
string GetCachedEpubFile(int chapterId, Chapter chapter);
|
||||
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
|
||||
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
|
||||
}
|
||||
public class CacheService : ICacheService
|
||||
{
|
||||
@ -35,16 +38,36 @@ namespace API.Services
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDirectoryService _directoryService;
|
||||
private readonly IReadingItemService _readingItemService;
|
||||
private readonly NumericComparer _numericComparer;
|
||||
private readonly IBookmarkService _bookmarkService;
|
||||
|
||||
public CacheService(ILogger<CacheService> logger, IUnitOfWork unitOfWork,
|
||||
IDirectoryService directoryService, IReadingItemService readingItemService)
|
||||
IDirectoryService directoryService, IReadingItemService readingItemService,
|
||||
IBookmarkService bookmarkService)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
_readingItemService = readingItemService;
|
||||
_numericComparer = new NumericComparer();
|
||||
_bookmarkService = bookmarkService;
|
||||
}
|
||||
|
||||
public string GetCachedBookmarkPagePath(int seriesId, int page)
|
||||
{
|
||||
// Calculate what chapter the page belongs to
|
||||
var path = GetBookmarkCachePath(seriesId);
|
||||
var files = _directoryService.GetFilesWithExtension(path, Parser.Parser.ImageFileExtensions);
|
||||
files = files
|
||||
.AsEnumerable()
|
||||
.OrderByNatural(Path.GetFileNameWithoutExtension)
|
||||
.ToArray();
|
||||
|
||||
if (files.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Since array is 0 based, we need to keep that in account (only affects last image)
|
||||
return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -146,6 +169,18 @@ namespace API.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cached files and folders for a set of chapterIds
|
||||
/// </summary>
|
||||
/// <param name="seriesIds"></param>
|
||||
public void CleanupBookmarks(IEnumerable<int> seriesIds)
|
||||
{
|
||||
foreach (var series in seriesIds)
|
||||
{
|
||||
_directoryService.ClearAndDeleteDirectory(GetBookmarkCachePath(series));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/
|
||||
@ -157,6 +192,11 @@ namespace API.Services
|
||||
return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{chapterId}/"));
|
||||
}
|
||||
|
||||
private string GetBookmarkCachePath(int seriesId)
|
||||
{
|
||||
return _directoryService.FileSystem.Path.GetFullPath(_directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, $"{seriesId}_bookmarks/"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the absolute path of a cached page.
|
||||
/// </summary>
|
||||
@ -181,5 +221,17 @@ namespace API.Services
|
||||
// Since array is 0 based, we need to keep that in account (only affects last image)
|
||||
return page == files.Length ? files.ElementAt(page - 1) : files.ElementAt(page);
|
||||
}
|
||||
|
||||
public async Task<int> CacheBookmarkForSeries(int userId, int seriesId)
|
||||
{
|
||||
var destDirectory = _directoryService.FileSystem.Path.Join(_directoryService.CacheDirectory, seriesId + "_bookmarks");
|
||||
if (_directoryService.Exists(destDirectory)) return _directoryService.GetFiles(destDirectory).Count();
|
||||
|
||||
var bookmarkDtos = await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(userId, seriesId);
|
||||
var files = (await _bookmarkService.GetBookmarkFilesById(bookmarkDtos.Select(b => b.Id))).ToList();
|
||||
_directoryService.CopyFilesToDirectory(files, destDirectory);
|
||||
_directoryService.Flatten(destDirectory);
|
||||
return files.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
11
UI/Web/src/app/_models/manga-reader/bookmark-info.ts
Normal file
11
UI/Web/src/app/_models/manga-reader/bookmark-info.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { LibraryType } from "../library";
|
||||
import { MangaFormat } from "../manga-format";
|
||||
|
||||
export interface BookmarkInfo {
|
||||
seriesName: string;
|
||||
seriesFormat: MangaFormat;
|
||||
seriesId: number;
|
||||
libraryId: number;
|
||||
libraryType: LibraryType;
|
||||
pages: number;
|
||||
}
|
@ -17,10 +17,17 @@ export enum Action {
|
||||
Info = 5,
|
||||
RefreshMetadata = 6,
|
||||
Download = 7,
|
||||
/**
|
||||
* @deprecated This is no longer supported. Use the dedicated page instead
|
||||
*/
|
||||
Bookmarks = 8,
|
||||
IncognitoRead = 9,
|
||||
AddToReadingList = 10,
|
||||
AddToCollection = 11
|
||||
AddToCollection = 11,
|
||||
/**
|
||||
* Essentially a download, but handled differently. Needed so card bubbles it up for handling
|
||||
*/
|
||||
DownloadBookmark = 12
|
||||
}
|
||||
|
||||
export interface ActionItem<T> {
|
||||
@ -47,6 +54,8 @@ export class ActionFactoryService {
|
||||
|
||||
readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||
|
||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
||||
|
||||
isAdmin = false;
|
||||
hasDownloadRole = false;
|
||||
|
||||
@ -181,6 +190,12 @@ export class ActionFactoryService {
|
||||
return actions;
|
||||
}
|
||||
|
||||
getBookmarkActions(callback: (action: Action, series: Series) => void) {
|
||||
const actions = this.bookmarkActions.map(a => {return {...a}});
|
||||
actions.forEach(action => action.callback = callback);
|
||||
return actions;
|
||||
}
|
||||
|
||||
filterBookmarksForFormat(action: ActionItem<Series>, series: Series) {
|
||||
if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false;
|
||||
return true;
|
||||
@ -206,12 +221,6 @@ export class ActionFactoryService {
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.Bookmarks,
|
||||
title: 'Bookmarks',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.AddToReadingList,
|
||||
title: 'Add to Reading List',
|
||||
@ -294,5 +303,20 @@ export class ActionFactoryService {
|
||||
requiresAdmin: false
|
||||
},
|
||||
];
|
||||
|
||||
this.bookmarkActions = [
|
||||
{
|
||||
action: Action.DownloadBookmark,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'Clear',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Subject } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
|
||||
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
|
||||
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
|
||||
@ -302,25 +301,6 @@ export class ActionService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
openBookmarkModal(series: Series, callback?: SeriesActionCallback) {
|
||||
if (this.bookmarkModalRef != null) { return; }
|
||||
this.bookmarkModalRef = this.modalService.open(BookmarksModalComponent, { scrollable: true, size: 'lg' });
|
||||
this.bookmarkModalRef.componentInstance.series = series;
|
||||
this.bookmarkModalRef.closed.pipe(take(1)).subscribe(() => {
|
||||
this.bookmarkModalRef = null;
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
});
|
||||
this.bookmarkModalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
this.bookmarkModalRef = null;
|
||||
if (callback) {
|
||||
callback(series);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: VoidActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md' });
|
||||
|
@ -4,6 +4,7 @@ import { environment } from 'src/environments/environment';
|
||||
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
|
||||
import { PageBookmark } from '../_models/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/progress-bookmark';
|
||||
import { Volume } from '../_models/volume';
|
||||
@ -48,6 +49,14 @@ export class ReaderService {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId});
|
||||
}
|
||||
|
||||
/**
|
||||
* Used exclusively for reading multiple bookmarks from a series
|
||||
* @param seriesId
|
||||
*/
|
||||
getBookmarkInfo(seriesId: number) {
|
||||
return this.httpClient.get<BookmarkInfo>(this.baseUrl + 'reader/bookmark-info?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
getProgress(chapterId: number) {
|
||||
return this.httpClient.get<ProgressBookmark>(this.baseUrl + 'reader/get-progress?chapterId=' + chapterId);
|
||||
}
|
||||
@ -56,6 +65,10 @@ export class ReaderService {
|
||||
return this.baseUrl + 'reader/image?chapterId=' + chapterId + '&page=' + page;
|
||||
}
|
||||
|
||||
getBookmarkPageUrl(seriesId: number, apiKey: string, page: number) {
|
||||
return this.baseUrl + 'reader/bookmark-image?seriesId=' + seriesId + '&page=' + page + '&apiKey=' + encodeURIComponent(apiKey);
|
||||
}
|
||||
|
||||
getChapterInfo(chapterId: number) {
|
||||
return this.httpClient.get<ChapterInfo>(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId);
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h2 title>
|
||||
<app-card-actionables [actions]="actions"></app-card-actionables>
|
||||
All Series
|
||||
</h2>
|
||||
<h6 subtitle>{{pagination?.totalItems}} Series</h6>
|
||||
|
@ -26,7 +26,6 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
|
||||
series: Series[] = [];
|
||||
loadingSeries = false;
|
||||
pagination!: Pagination;
|
||||
actions: ActionItem<Library>[] = [];
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
onDestroy: Subject<void> = new Subject<void>();
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
|
@ -2,7 +2,6 @@ import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { LibraryDetailComponent } from './library-detail/library-detail.component';
|
||||
import { SeriesDetailComponent } from './series-detail/series-detail.component';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
import { UserLoginComponent } from './user-login/user-login.component';
|
||||
import { AuthGuard } from './_guards/auth.guard';
|
||||
import { LibraryAccessGuard } from './_guards/library-access.guard';
|
||||
@ -10,8 +9,6 @@ import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { AllSeriesComponent } from './all-series/all-series.component';
|
||||
import { AdminGuard } from './_guards/admin.guard';
|
||||
import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
import { ReadingListsComponent } from './reading-list/reading-lists/reading-lists.component';
|
||||
import { AllCollectionsComponent } from './collections/all-collections/all-collections.component';
|
||||
|
||||
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
|
||||
|
||||
@ -45,6 +42,10 @@ const routes: Routes = [
|
||||
path: 'announcements',
|
||||
loadChildren: () => import('../app/announcements/announcements.module').then(m => m.AnnouncementsModule)
|
||||
},
|
||||
{
|
||||
path: 'bookmarks',
|
||||
loadChildren: () => import('../app/bookmark/bookmark.module').then(m => m.BookmarkModule)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
runGuardsAndResolvers: 'always',
|
||||
@ -68,8 +69,7 @@ const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{path: 'library', component: DashboardComponent},
|
||||
{path: 'recently-added', component: RecentlyAddedComponent},
|
||||
{path: 'all-series', component: AllSeriesComponent},
|
||||
{path: 'all-series', component: AllSeriesComponent}, // TODO: This might be better as a separate module
|
||||
|
||||
]
|
||||
},
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { BrowserModule, Title } from '@angular/platform-browser';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { APP_BASE_HREF } from '@angular/common';
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
@ -22,7 +21,6 @@ import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review
|
||||
import { CarouselModule } from './carousel/carousel.module';
|
||||
|
||||
import { TypeaheadModule } from './typeahead/typeahead.module';
|
||||
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { CardsModule } from './cards/cards.module';
|
||||
import { CollectionsModule } from './collections/collections.module';
|
||||
@ -35,7 +33,6 @@ import { RegistrationModule } from './registration/registration.module';
|
||||
import { GroupedTypeaheadComponent } from './grouped-typeahead/grouped-typeahead.component';
|
||||
import { ThemeTestComponent } from './theme-test/theme-test.component';
|
||||
import { PipeModule } from './pipe/pipe.module';
|
||||
import { ColorPickerModule } from 'ngx-color-picker';
|
||||
import { SidenavModule } from './sidenav/sidenav.module';
|
||||
|
||||
|
||||
@ -48,7 +45,6 @@ import { SidenavModule } from './sidenav/sidenav.module';
|
||||
LibraryDetailComponent,
|
||||
SeriesDetailComponent,
|
||||
ReviewSeriesModalComponent,
|
||||
RecentlyAddedComponent,
|
||||
DashboardComponent,
|
||||
EventsWidgetComponent,
|
||||
SeriesMetadataDetailComponent,
|
||||
@ -66,8 +62,9 @@ import { SidenavModule } from './sidenav/sidenav.module';
|
||||
|
||||
NgbDropdownModule, // Nav
|
||||
NgbPopoverModule, // Nav Events toggle
|
||||
NgbRatingModule, // Series Detail & Filter
|
||||
NgbNavModule,
|
||||
|
||||
NgbRatingModule, // Series Detail & Filter
|
||||
NgbPaginationModule,
|
||||
|
||||
NgbCollapseModule, // Login
|
||||
@ -80,8 +77,6 @@ import { SidenavModule } from './sidenav/sidenav.module';
|
||||
ReadingListModule,
|
||||
RegistrationModule,
|
||||
|
||||
ColorPickerModule, // User preferences
|
||||
|
||||
NgbAccordionModule, // ThemeTest Component only
|
||||
PipeModule,
|
||||
|
||||
|
22
UI/Web/src/app/bookmark/bookmark-routing.module.ts
Normal file
22
UI/Web/src/app/bookmark/bookmark-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 { BookmarksComponent } from "./bookmarks/bookmarks.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{path: '**', component: BookmarksComponent, pathMatch: 'full', canActivate: [AuthGuard]},
|
||||
{
|
||||
runGuardsAndResolvers: 'always',
|
||||
canActivate: [AuthGuard],
|
||||
children: [
|
||||
{path: '/bookmarks', component: BookmarksComponent},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class BookmarkRoutingModule { }
|
26
UI/Web/src/app/bookmark/bookmark.module.ts
Normal file
26
UI/Web/src/app/bookmark/bookmark.module.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CardsModule } from '../cards/cards.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SidenavModule } from '../sidenav/sidenav.module';
|
||||
import { BookmarkRoutingModule } from './bookmark-routing.module';
|
||||
import { BookmarksComponent } from './bookmarks/bookmarks.component';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
BookmarksComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
CardsModule,
|
||||
SharedModule,
|
||||
SidenavModule,
|
||||
NgbTooltipModule,
|
||||
|
||||
BookmarkRoutingModule
|
||||
]
|
||||
})
|
||||
export class BookmarkModule { }
|
23
UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html
Normal file
23
UI/Web/src/app/bookmark/bookmarks/bookmarks.component.html
Normal file
@ -0,0 +1,23 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="false">
|
||||
<h2 title>
|
||||
<app-card-actionables [actions]="actions"></app-card-actionables>
|
||||
Bookmarks
|
||||
</h2>
|
||||
<h6 subtitle style="margin-left:40px;">{{series?.length}} Series</h6>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="loadingBookmarks"
|
||||
[items]="series">
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item [entity]="item" (reload)="loadBookmarks()" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
|
||||
[supressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
|
||||
[actions]="actions"
|
||||
[selected]="bulkSelectionService.isCardSelected('bookmark', position)" (selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
|
||||
></app-card-item>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
There are no bookmarks. Try creating <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/bookmarks" target="_blank">one <i class="fa fa-external-link-alt" aria-hidden="true"></i></a>.
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
167
UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts
Normal file
167
UI/Web/src/app/bookmark/bookmarks/bookmarks.component.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take, takeWhile, finalize, Subject, forkJoin } from 'rxjs';
|
||||
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks',
|
||||
templateUrl: './bookmarks.component.html',
|
||||
styleUrls: ['./bookmarks.component.scss']
|
||||
})
|
||||
export class BookmarksComponent implements OnInit, OnDestroy {
|
||||
|
||||
bookmarks: Array<PageBookmark> = [];
|
||||
series: Array<Series> = [];
|
||||
loadingBookmarks: boolean = false;
|
||||
seriesIds: {[id: number]: number} = {};
|
||||
downloadingSeries: {[id: number]: boolean} = {};
|
||||
clearingSeries: {[id: number]: boolean} = {};
|
||||
actions: ActionItem<Series>[] = [];
|
||||
|
||||
private onDestroy: Subject<void> = new Subject<void>();
|
||||
|
||||
constructor(private readerService: ReaderService, private seriesService: SeriesService,
|
||||
private downloadService: DownloadService, private toastr: ToastrService,
|
||||
private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService,
|
||||
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
|
||||
private router: Router) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadBookmarks();
|
||||
|
||||
this.actions = this.actionFactoryService.getBookmarkActions(this.handleAction.bind(this));
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleAction(action: Action, series: Series) {
|
||||
switch (action) {
|
||||
case(Action.Delete):
|
||||
if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for ' + series.name + '? This cannot be undone.')) {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case(Action.DownloadBookmark):
|
||||
this.downloadBookmarks(series);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bulkActionCallback = async (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('bookmark');
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||
const seriesIds = selectedSeries.map(item => item.id);
|
||||
|
||||
switch (action) {
|
||||
case Action.DownloadBookmark:
|
||||
this.downloadService.downloadBookmarks(this.bookmarks.filter(bmk => seriesIds.includes(bmk.seriesId))).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
})).subscribe(() => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
if (!await this.confirmService.confirm('Are you sure you want to clear all bookmarks for multiple series? This cannot be undone.')) {
|
||||
break;
|
||||
}
|
||||
|
||||
forkJoin(seriesIds.map(id => this.readerService.clearBookmarks(id))).subscribe(() => {
|
||||
this.toastr.success('Bookmarks have been removed');
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadBookmarks();
|
||||
})
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
this.router.navigate(['library', series.libraryId, 'series', series.id, 'manga', 0], {queryParams: {incognitoMode: false, bookmarkMode: true}});
|
||||
}
|
||||
|
||||
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 */});
|
||||
}
|
||||
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}} Bookmarks</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p *ngIf="bookmarks.length > 0; else noBookmarks">
|
||||
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
|
||||
</p>
|
||||
<ng-template #noBookmarks>No bookmarks yet</ng-template>
|
||||
|
||||
<div class="row g-0">
|
||||
<div *ngFor="let bookmark of bookmarks; let idx = index">
|
||||
<app-bookmark [bookmark]="bookmark" (bookmarkRemoved)="removeBookmark(bookmark, idx)" class="col-auto"></app-bookmark>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="clearBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
|
||||
<span *ngIf="isClearing" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Clear{{isClearing ? 'ing...' : ''}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" (click)="downloadBookmarks()" [disabled]="(isDownloading || isClearing) || bookmarks.length === 0">
|
||||
<span *ngIf="isDownloading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Download{{isDownloading ? 'ing...' : ''}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="close()">Close</button>
|
||||
</div>
|
@ -1,77 +0,0 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
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';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bookmarks-modal',
|
||||
templateUrl: './bookmarks-modal.component.html',
|
||||
styleUrls: ['./bookmarks-modal.component.scss']
|
||||
})
|
||||
export class BookmarksModalComponent implements OnInit {
|
||||
|
||||
@Input() series!: Series;
|
||||
|
||||
bookmarks: Array<PageBookmark> = [];
|
||||
title: string = '';
|
||||
subtitle: string = '';
|
||||
isDownloading: boolean = false;
|
||||
isClearing: boolean = false;
|
||||
|
||||
uniqueChapters: number = 0;
|
||||
|
||||
constructor(public imageService: ImageService, private readerService: ReaderService,
|
||||
public modal: NgbActiveModal, private downloadService: DownloadService,
|
||||
private toastr: ToastrService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.readerService.getBookmarksForSeries(this.series.id).pipe(take(1)).subscribe(bookmarks => {
|
||||
this.bookmarks = bookmarks;
|
||||
const chapters: {[id: number]: string} = {};
|
||||
this.bookmarks.forEach(bmk => {
|
||||
if (!chapters.hasOwnProperty(bmk.chapterId)) {
|
||||
chapters[bmk.chapterId] = '';
|
||||
}
|
||||
});
|
||||
this.uniqueChapters = Object.keys(chapters).length;
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
|
||||
removeBookmark(bookmark: PageBookmark, index: number) {
|
||||
this.bookmarks.splice(index, 1);
|
||||
}
|
||||
|
||||
downloadBookmarks() {
|
||||
this.isDownloading = true;
|
||||
this.downloadService.downloadBookmarks(this.bookmarks).pipe(
|
||||
takeWhile(val => {
|
||||
return val.state != 'DONE';
|
||||
}),
|
||||
finalize(() => {
|
||||
this.isDownloading = false;
|
||||
})).subscribe(() => {/* No Operation */});
|
||||
}
|
||||
|
||||
clearBookmarks() {
|
||||
this.isClearing = true;
|
||||
this.readerService.clearBookmarks(this.series.id).subscribe(() => {
|
||||
this.isClearing = false;
|
||||
this.init();
|
||||
this.toastr.success(this.series.name + '\'s bookmarks have been removed');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
@ -89,20 +89,6 @@
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[3]" *ngIf="!tabs[3].disabled">
|
||||
<a ngbNavLink>{{tabs[3].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let bookmark of bookmarks; let idx = index">
|
||||
<app-bookmark [bookmark]="bookmark" class="col-auto" (bookmarkRemoved)="removeBookmark(bookmark, idx)"></app-bookmark>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="bookmarks.length === 0">
|
||||
No bookmarks yet
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[4]" *ngIf="!tabs[4].disabled">
|
||||
<a ngbNavLink>{{tabs[4].title}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
@ -66,9 +66,8 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
chapterActions: ActionItem<Chapter>[] = [];
|
||||
libraryType: LibraryType = LibraryType.Manga;
|
||||
|
||||
bookmarks: PageBookmark[] = [];
|
||||
|
||||
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Bookmarks', disabled: false}, {title: 'Info', disabled: false}];
|
||||
tabs = [{title: 'General', disabled: false}, {title: 'Metadata', disabled: false}, {title: 'Cover', disabled: false}, {title: 'Info', disabled: false}];
|
||||
active = this.tabs[0];
|
||||
|
||||
chapterMetadata!: ChapterMetadata;
|
||||
@ -100,17 +99,6 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
|
||||
this.imageUrls.push(this.imageService.getChapterCoverImage(this.chapter.id));
|
||||
|
||||
let bookmarkApi;
|
||||
if (this.isChapter) {
|
||||
bookmarkApi = this.readerService.getBookmarks(this.chapter.id);
|
||||
} else {
|
||||
bookmarkApi = this.readerService.getBookmarksForVolume(this.data.id);
|
||||
}
|
||||
|
||||
bookmarkApi.pipe(take(1)).subscribe(bookmarks => {
|
||||
this.bookmarks = bookmarks;
|
||||
});
|
||||
|
||||
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
|
||||
this.chapterMetadata = metadata;
|
||||
|
||||
@ -240,10 +228,4 @@ export class CardDetailsModalComponent implements OnInit {
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'manga', chapter.id]);
|
||||
}
|
||||
}
|
||||
|
||||
removeBookmark(bookmark: PageBookmark, index: number) {
|
||||
this.readerService.unbookmark(bookmark.seriesId, bookmark.volumeId, bookmark.chapterId, bookmark.page).subscribe(() => {
|
||||
this.bookmarks.splice(index, 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { NavigationStart, Router } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Action, ActionFactoryService } from '../_services/action-factory.service';
|
||||
|
||||
type DataSource = 'volume' | 'chapter' | 'special' | 'series';
|
||||
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark';
|
||||
|
||||
/**
|
||||
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
|
||||
@ -132,6 +132,10 @@ export class BulkSelectionService {
|
||||
return this.actionFactory.getSeriesActions(callback).filter(item => allowedActions.includes(item.action));
|
||||
}
|
||||
|
||||
if (Object.keys(this.selectedCards).filter(item => item === 'bookmark').length > 0) {
|
||||
return this.actionFactory.getBookmarkActions(callback);
|
||||
}
|
||||
|
||||
return this.actionFactory.getVolumeActions(callback).filter(item => allowedActions.includes(item.action));
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@
|
||||
</div>
|
||||
|
||||
<p *ngIf="items.length === 0 && !isLoading">
|
||||
There is no data
|
||||
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -73,3 +73,8 @@
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">
|
||||
<div class="spinner-border text-secondary loading" role="status">
|
||||
<span class="invisible">Loading...</span>
|
||||
</div>
|
||||
</div>
|
@ -38,6 +38,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
|
||||
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
|
||||
|
||||
|
||||
// Filter Code
|
||||
|
@ -5,7 +5,6 @@ import { LibraryCardComponent } from './library-card/library-card.component';
|
||||
import { CoverImageChooserComponent } from './cover-image-chooser/cover-image-chooser.component';
|
||||
import { EditSeriesModalComponent } from './_modals/edit-series-modal/edit-series-modal.component';
|
||||
import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit-collection-tags.component';
|
||||
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
@ -21,7 +20,6 @@ import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/b
|
||||
import { PipeModule } from '../pipe/pipe.module';
|
||||
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
|
||||
import { FileInfoComponent } from './file-info/file-info.component';
|
||||
import { BookmarkComponent } from './bookmark/bookmark.component';
|
||||
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
|
||||
|
||||
|
||||
@ -34,7 +32,6 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent,
|
||||
@ -42,7 +39,6 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
|
||||
BulkAddToCollectionComponent,
|
||||
ChapterMetadataDetailComponent,
|
||||
FileInfoComponent,
|
||||
BookmarkComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -79,7 +75,6 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesModalComponent,
|
||||
EditCollectionTagsComponent,
|
||||
BookmarksModalComponent,
|
||||
CardActionablesComponent,
|
||||
CardDetailLayoutComponent,
|
||||
CardDetailsModalComponent,
|
||||
|
@ -96,9 +96,6 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
|
||||
case(Action.Edit):
|
||||
this.openEditModal(series);
|
||||
break;
|
||||
case(Action.Bookmarks):
|
||||
this.actionService.openBookmarkModal(series, (series) => {/* No Operation */ });
|
||||
break;
|
||||
case(Action.AddToReadingList):
|
||||
this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ });
|
||||
break;
|
||||
|
@ -21,7 +21,7 @@
|
||||
<!-- {{this.pageNum}} -->
|
||||
<!-- {{readerService.imageUrlToPageNum(canvasImage.src)}}<ng-container *ngIf="ShouldRenderDoublePage && (this.pageNum + 1 <= maxPages - 1 && this.pageNum > 0)"> - {{PageNumber + 1}}</ng-container> -->
|
||||
|
||||
<button class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="isCurrentPageBookmarked" title="{{isCurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{isCurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="visually-hidden">{{isCurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
|
||||
<button *ngIf="!bookmarkMode" class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="isCurrentPageBookmarked" title="{{isCurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{isCurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="visually-hidden">{{isCurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -47,7 +47,7 @@
|
||||
class="{{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}}">
|
||||
|
||||
<ng-container *ngIf="ShouldRenderDoublePage && (this.pageNum + 1 <= maxPages - 1 && this.pageNum > 0)">
|
||||
<img [src]="readerService.getPageUrl(this.chapterId, PageNumber + 1)" id="image-2" class="image-2 {{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}} {{ShouldRenderReverseDouble ? 'reverse' : ''}}">
|
||||
<img [src]="getPageUrl(PageNumber + 1)" id="image-2" class="image-2 {{getFittingOptionClass()}} {{readerMode === ReaderMode.LeftRight || readerMode === ReaderMode.UpDown ? '' : 'd-none'}} {{showClickOverlay ? 'blur' : ''}} {{ShouldRenderReverseDouble ? 'reverse' : ''}}">
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
|
@ -27,6 +27,7 @@ import { LibraryType } from '../_models/library';
|
||||
import { ShorcutsModalComponent } from '../reader-shared/_modals/shorcuts-modal/shorcuts-modal.component';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { LayoutMode } from './_models/layout-mode';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
||||
const PREFETCH_PAGES = 8;
|
||||
|
||||
@ -79,6 +80,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
* If this is true, no progress will be saved.
|
||||
*/
|
||||
incognitoMode: boolean = false;
|
||||
/**
|
||||
* If this is true, we are reading a bookmark. ChapterId will be 0. There is no continuous reading. Progress is not saved. Bookmark control is removed.
|
||||
*/
|
||||
bookmarkMode: boolean = false;
|
||||
|
||||
/**
|
||||
* If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
|
||||
@ -257,7 +262,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
|
||||
getPageUrl = (pageNum: number) => this.readerService.getPageUrl(this.chapterId, pageNum);
|
||||
//getPageUrl = (pageNum: number) => this.readerService.getPageUrl(this.chapterId, pageNum);
|
||||
|
||||
get PageNumber() {
|
||||
return Math.max(Math.min(this.pageNum, this.maxPages - 1), 0);
|
||||
@ -326,7 +331,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private formBuilder: FormBuilder, private navService: NavService,
|
||||
private toastr: ToastrService, private memberService: MemberService,
|
||||
public utilityService: UtilityService, private renderer: Renderer2,
|
||||
@Inject(DOCUMENT) private document: Document, private modalService: NgbModal) {
|
||||
@Inject(DOCUMENT) private document: Document, private modalService: NgbModal,
|
||||
private seriesService: SeriesService) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
@ -347,6 +353,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.seriesId = parseInt(seriesId, 10);
|
||||
this.chapterId = parseInt(chapterId, 10);
|
||||
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
|
||||
this.bookmarkMode = this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true';
|
||||
|
||||
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
|
||||
if (readingListId != null) {
|
||||
@ -506,12 +513,42 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.goToPageEvent.complete();
|
||||
}
|
||||
|
||||
if (this.bookmarkMode) {
|
||||
this.readerService.getBookmarkInfo(this.seriesId).subscribe(bookmarkInfo => {
|
||||
this.setPageNum(0);
|
||||
this.title = bookmarkInfo.seriesName + ' Bookmarks';
|
||||
this.libraryType = bookmarkInfo.libraryType;
|
||||
this.maxPages = bookmarkInfo.pages;
|
||||
|
||||
// Due to change detection rules in Angular, we need to re-create the options object to apply the change
|
||||
const newOptions: Options = Object.assign({}, this.pageOptions);
|
||||
newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
|
||||
this.pageOptions = newOptions;
|
||||
this.inSetup = false;
|
||||
this.prevChapterDisabled = true;
|
||||
this.nextChapterDisabled = true;
|
||||
|
||||
const images = [];
|
||||
for (let i = 0; i < PREFETCH_PAGES + 2; i++) {
|
||||
images.push(new Image());
|
||||
}
|
||||
|
||||
this.cachedImages = new CircularArray<HTMLImageElement>(images, 0);
|
||||
|
||||
this.render();
|
||||
});
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
forkJoin({
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
chapterInfo: this.readerService.getChapterInfo(this.chapterId),
|
||||
bookmarks: this.readerService.getBookmarks(this.chapterId),
|
||||
}).pipe(take(1)).subscribe(results => {
|
||||
|
||||
|
||||
if (this.readingListMode && results.chapterInfo.seriesFormat === MangaFormat.EPUB) {
|
||||
// Redirect to the book reader.
|
||||
const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
@ -1049,7 +1086,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
if (offsetIndex < this.maxPages - 1) {
|
||||
item.src = this.readerService.getPageUrl(this.chapterId, offsetIndex);
|
||||
// if (this.bookmarkMode) item.src = this.readerService.getBookmarkPageUrl(this.seriesId, offsetIndex);
|
||||
// else item.src = this.readerService.getPageUrl(this.chapterId, offsetIndex);
|
||||
item.src = this.getPageUrl(offsetIndex);
|
||||
index += 1;
|
||||
}
|
||||
}, this.cachedImages.size() - 3);
|
||||
@ -1057,6 +1096,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
//console.log('cachedImages: ', this.cachedImages.arr.map(img => this.readerService.imageUrlToPageNum(img.src) + ': ' + img.complete));
|
||||
}
|
||||
|
||||
getPageUrl(pageNum: number) {
|
||||
if (this.bookmarkMode) return this.readerService.getBookmarkPageUrl(this.seriesId, this.user.apiKey, pageNum);
|
||||
return this.readerService.getPageUrl(this.chapterId, pageNum);
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
if (!this.canvas || !this.ctx) { return; }
|
||||
|
||||
@ -1067,10 +1111,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
if (this.readerService.imageUrlToPageNum(this.canvasImage.src) !== this.pageNum || this.canvasImage.src === '' || !this.canvasImage.complete) {
|
||||
if (this.layoutMode === LayoutMode.Single) {
|
||||
this.canvasImage.src = this.readerService.getPageUrl(this.chapterId, this.pageNum);
|
||||
//this.canvasImage.src = this.readerService.getPageUrl(this.chapterId, this.pageNum);
|
||||
this.canvasImage.src = this.getPageUrl(this.pageNum);
|
||||
} else {
|
||||
this.canvasImage.src = this.readerService.getPageUrl(this.chapterId, this.pageNum);
|
||||
this.canvasImage2.src = this.readerService.getPageUrl(this.chapterId, this.pageNum + 1); // TODO: I need to handle last page correctly
|
||||
// this.canvasImage.src = this.readerService.getPageUrl(this.chapterId, this.pageNum);
|
||||
// this.canvasImage2.src = this.readerService.getPageUrl(this.chapterId, this.pageNum + 1); // TODO: I need to handle last page correctly
|
||||
this.canvasImage.src = this.getPageUrl(this.pageNum);
|
||||
this.canvasImage2.src = this.getPageUrl(this.pageNum + 1); // TODO: I need to handle last page correctly
|
||||
}
|
||||
this.canvasImage.onload = () => this.renderPage();
|
||||
|
||||
@ -1143,7 +1190,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
tempPageNum = this.pageNum + 1;
|
||||
}
|
||||
|
||||
if (!this.incognitoMode) {
|
||||
if (!this.incognitoMode || !this.bookmarkMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, tempPageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
}
|
||||
@ -1297,7 +1344,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
|
||||
window.history.replaceState({}, '', newRoute);
|
||||
this.toastr.info('Incognito mode is off. Progress will now start being tracked.');
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
if (!this.bookmarkMode) {
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
}
|
||||
|
||||
getWindowDimensions() {
|
||||
|
@ -8,7 +8,7 @@
|
||||
<app-drawer #commentDrawer="drawer" [isOpen]="!filteringCollapsed" [style.--drawer-width]="'300px'" [options]="{topOffset: 56}" (drawerClosed)="filteringCollapsed = !filteringCollapsed">
|
||||
<div header>
|
||||
<h2 style="margin-top: 0.5rem">Book Settings
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="commentDrawer.close()"></button>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</h2>
|
||||
</div>
|
||||
<div body class="drawer-body">
|
||||
|
@ -17,6 +17,7 @@ import { Tag } from '../_models/tag';
|
||||
import { CollectionTagService } from '../_services/collection-tag.service';
|
||||
import { LibraryService } from '../_services/library.service';
|
||||
import { MetadataService } from '../_services/metadata.service';
|
||||
import { NavService } from '../_services/nav.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
import { FilterSettings } from './filter-settings';
|
||||
|
||||
@ -42,6 +43,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
|
||||
|
||||
@ContentChild('[ngbCollapse]') collapse!: NgbCollapse;
|
||||
//@ContentChild('commentDrawer') commentDrawer:
|
||||
|
||||
|
||||
formatSettings: TypeaheadSettings<FilterItem<MangaFormat>> = new TypeaheadSettings();
|
||||
@ -74,7 +76,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
fullyLoaded: boolean = false;
|
||||
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
get PersonRole(): typeof PersonRole {
|
||||
return PersonRole;
|
||||
@ -94,7 +96,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
if (this.filterOpen) {
|
||||
this.filterOpen.pipe(takeUntil(this.onDestory)).subscribe(openState => {
|
||||
this.filterOpen.pipe(takeUntil(this.onDestroy)).subscribe(openState => {
|
||||
this.filteringCollapsed = !openState;
|
||||
});
|
||||
}
|
||||
@ -114,7 +116,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
seriesNameQuery: new FormControl({value: this.filter.seriesNameQuery || '', disabled: this.filterSettings.searchNameDisabled}, [])
|
||||
});
|
||||
|
||||
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
this.readProgressGroup.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => {
|
||||
this.filter.readStatus.read = this.readProgressGroup.get('read')?.value;
|
||||
this.filter.readStatus.inProgress = this.readProgressGroup.get('inProgress')?.value;
|
||||
this.filter.readStatus.notRead = this.readProgressGroup.get('notRead')?.value;
|
||||
@ -135,7 +137,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.sortGroup.valueChanges.pipe(takeUntil(this.onDestory)).subscribe(changes => {
|
||||
this.sortGroup.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(changes => {
|
||||
if (this.filter.sortOptions == null) {
|
||||
this.filter.sortOptions = {
|
||||
isAscending: this.isAscendingSort,
|
||||
@ -148,7 +150,7 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
this.seriesNameGroup.get('seriesNameQuery')?.valueChanges.pipe(
|
||||
map(val => (val || '').trim()),
|
||||
distinctUntilChanged(),
|
||||
takeUntil(this.onDestory))
|
||||
takeUntil(this.onDestroy))
|
||||
.subscribe(changes => {
|
||||
this.filter.seriesNameQuery = changes;
|
||||
});
|
||||
@ -156,9 +158,14 @@ export class MetadataFilterComponent implements OnInit, OnDestroy {
|
||||
this.loadFromPresetsAndSetup();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.filterOpen.emit(false);
|
||||
this.filteringCollapsed = true;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { from, fromEvent, Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { ScrollService } from '../scroll.service';
|
||||
import { FilterQueryParam, FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Library } from '../_models/library';
|
||||
import { PersonRole } from '../_models/person';
|
||||
@ -47,28 +48,17 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
|
||||
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
|
||||
private scrollService: ScrollService) { }
|
||||
private scrollService: ScrollService, private filterUtilityService: FilterUtilitiesService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// this.navService.darkMode$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
|
||||
// if (res) {
|
||||
// this.document.body.classList.remove('bg-light');
|
||||
// this.document.body.classList.add('bg-dark');
|
||||
// } else {
|
||||
// this.document.body.classList.remove('bg-dark');
|
||||
// this.document.body.classList.add('bg-light');
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
@HostListener("window:scroll", [])
|
||||
checkBackToTopNeeded() {
|
||||
const offset = this.scrollService.scrollPosition;
|
||||
if (offset > 100) {
|
||||
this.backToTopNeeded = true;
|
||||
} else if (offset < 40) {
|
||||
this.backToTopNeeded = false;
|
||||
}
|
||||
fromEvent(this.document.body, 'scroll').pipe(takeUntil(this.onDestroy)).subscribe(() => {
|
||||
const offset = this.scrollService.scrollPosition;
|
||||
if (offset > 100) {
|
||||
this.backToTopNeeded = true;
|
||||
} else if (offset < 40) {
|
||||
this.backToTopNeeded = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@ -106,50 +96,46 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
goTo(queryParamName: string, filter: any) {
|
||||
let params: any = {};
|
||||
params[queryParamName] = filter;
|
||||
params['page'] = 1;
|
||||
params[FilterQueryParam.Page] = 1;
|
||||
this.clearSearch();
|
||||
this.router.navigate(['all-series'], {queryParams: params});
|
||||
}
|
||||
|
||||
goToPerson(role: PersonRole, filter: any) {
|
||||
// TODO: Move this to utility service
|
||||
this.clearSearch();
|
||||
switch(role) {
|
||||
case PersonRole.Writer:
|
||||
this.goTo('writers', filter);
|
||||
this.goTo(FilterQueryParam.Writers, filter);
|
||||
break;
|
||||
case PersonRole.Artist:
|
||||
this.goTo('artists', filter);
|
||||
this.goTo(FilterQueryParam.Artists, filter);
|
||||
break;
|
||||
case PersonRole.Character:
|
||||
this.goTo('character', filter);
|
||||
this.goTo(FilterQueryParam.Character, filter);
|
||||
break;
|
||||
case PersonRole.Colorist:
|
||||
this.goTo('colorist', filter);
|
||||
this.goTo(FilterQueryParam.Colorist, filter);
|
||||
break;
|
||||
case PersonRole.Editor:
|
||||
this.goTo('editor', filter);
|
||||
this.goTo(FilterQueryParam.Editor, filter);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.goTo('inker', filter);
|
||||
this.goTo(FilterQueryParam.Inker, filter);
|
||||
break;
|
||||
case PersonRole.CoverArtist:
|
||||
this.goTo('coverArtists', filter);
|
||||
break;
|
||||
case PersonRole.Inker:
|
||||
this.goTo('inker', filter);
|
||||
this.goTo(FilterQueryParam.CoverArtists, filter);
|
||||
break;
|
||||
case PersonRole.Letterer:
|
||||
this.goTo('letterer', filter);
|
||||
this.goTo(FilterQueryParam.Letterer, filter);
|
||||
break;
|
||||
case PersonRole.Penciller:
|
||||
this.goTo('penciller', filter);
|
||||
this.goTo(FilterQueryParam.Penciller, filter);
|
||||
break;
|
||||
case PersonRole.Publisher:
|
||||
this.goTo('publisher', filter);
|
||||
this.goTo(FilterQueryParam.Publisher, filter);
|
||||
break;
|
||||
case PersonRole.Translator:
|
||||
this.goTo('translator', filter);
|
||||
this.goTo(FilterQueryParam.Translator, filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -183,10 +169,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
|
||||
|
||||
scrollToTop() {
|
||||
window.scroll({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
this.scrollService.scrollTo(0, this.document.body);
|
||||
}
|
||||
|
||||
focusUpdate(searchFocused: boolean) {
|
||||
|
@ -1,19 +0,0 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" [filterOpenByDefault]="filterSettings.openByDefault" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h2 title>
|
||||
Recently Added
|
||||
</h2>
|
||||
</app-side-nav-companion-bar>
|
||||
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="series"
|
||||
[pagination]="pagination"
|
||||
[filterSettings]="filterSettings"
|
||||
[filterOpen]="filterOpen"
|
||||
(applyFilter)="applyFilter($event)"
|
||||
(pageChange)="onPageChange($event)"
|
||||
>
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-series-card [data]="item" [libraryId]="item.libraryId" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
@ -1,159 +0,0 @@
|
||||
import { Component, EventEmitter, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subject } from 'rxjs';
|
||||
import { debounceTime, take, takeUntil } from 'rxjs/operators';
|
||||
import { BulkSelectionService } from '../cards/bulk-selection.service';
|
||||
import { FilterSettings } from '../metadata-filter/filter-settings';
|
||||
import { KEY_CODES, UtilityService } from '../shared/_services/utility.service';
|
||||
import { SeriesAddedEvent } from '../_models/events/series-added-event';
|
||||
import { Pagination } from '../_models/pagination';
|
||||
import { Series } from '../_models/series';
|
||||
import { FilterEvent, SeriesFilter } from '../_models/series-filter';
|
||||
import { Action } from '../_services/action-factory.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
|
||||
import { SeriesService } from '../_services/series.service';
|
||||
|
||||
// TODO: Do I still need this or can All Series handle it with a custom sort
|
||||
|
||||
/**
|
||||
* This component is used as a standard layout for any card detail. ie) series, on-deck, collections, etc.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-recently-added',
|
||||
templateUrl: './recently-added.component.html',
|
||||
styleUrls: ['./recently-added.component.scss']
|
||||
})
|
||||
export class RecentlyAddedComponent implements OnInit, OnDestroy {
|
||||
|
||||
isLoading: boolean = true;
|
||||
series: Series[] = [];
|
||||
pagination!: Pagination;
|
||||
libraryId!: number;
|
||||
|
||||
filter: SeriesFilter | undefined = undefined;
|
||||
filterSettings: FilterSettings = new FilterSettings();
|
||||
filterOpen: EventEmitter<boolean> = new EventEmitter();
|
||||
filterActive: boolean = false;
|
||||
|
||||
onDestroy: Subject<void> = new Subject();
|
||||
|
||||
constructor(private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private titleService: Title,
|
||||
private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService,
|
||||
private utilityService: UtilityService) {
|
||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||
this.titleService.setTitle('Kavita - Recently Added');
|
||||
if (this.pagination === undefined || this.pagination === null) {
|
||||
this.pagination = {currentPage: 1, itemsPerPage: 30, totalItems: 0, totalPages: 1};
|
||||
}
|
||||
this.filterSettings.sortDisabled = true;
|
||||
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.SHIFT) {
|
||||
this.bulkSelectionService.isShiftDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.hubService.messages$.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (event.event !== EVENTS.SeriesAdded) return;
|
||||
const seriesAdded = event.payload as SeriesAddedEvent;
|
||||
if (seriesAdded.libraryId !== this.libraryId) return;
|
||||
this.loadPage();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
seriesClicked(series: Series) {
|
||||
this.router.navigate(['library', this.libraryId, 'series', series.id]);
|
||||
}
|
||||
|
||||
onPageChange(pagination: Pagination) {
|
||||
window.history.replaceState(window.location.href, '', window.location.href.split('?')[0] + '?page=' + this.pagination.currentPage);
|
||||
this.loadPage();
|
||||
}
|
||||
|
||||
applyFilter(event: FilterEvent) {
|
||||
this.filter = event.filter;
|
||||
const page = this.getPage();
|
||||
if (page === undefined || page === null || !event.isFirst) {
|
||||
this.pagination.currentPage = 1;
|
||||
this.onPageChange(this.pagination);
|
||||
} else {
|
||||
this.loadPage();
|
||||
}
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
const page = this.getPage();
|
||||
if (page != null) {
|
||||
this.pagination.currentPage = parseInt(page, 10);
|
||||
}
|
||||
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterSettings.presets);
|
||||
this.isLoading = true;
|
||||
this.seriesService.getRecentlyAdded(this.libraryId, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
|
||||
this.series = series.result;
|
||||
this.pagination = series.pagination;
|
||||
this.isLoading = false;
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
getPage() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('page');
|
||||
}
|
||||
|
||||
bulkActionCallback = (action: Action, data: any) => {
|
||||
const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series');
|
||||
const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + ''));
|
||||
|
||||
switch (action) {
|
||||
case Action.AddToReadingList:
|
||||
this.actionService.addMultipleSeriesToReadingList(selectedSeries, () => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.AddToCollection:
|
||||
this.actionService.addMultipleSeriesToCollectionTag(selectedSeries, () => {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.MarkAsRead:
|
||||
this.actionService.markMultipleSeriesAsRead(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
|
||||
break;
|
||||
case Action.MarkAsUnread:
|
||||
this.actionService.markMultipleSeriesAsUnread(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
case Action.Delete:
|
||||
this.actionService.deleteMultipleSeries(selectedSeries, () => {
|
||||
this.loadPage();
|
||||
this.bulkSelectionService.deselectAll();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
@ -72,13 +72,18 @@
|
||||
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
|
||||
<a ngbNavLink>Specials</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let chapter of specials; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto p-2" [entity]="chapter" [title]="chapter.title || chapter.range" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('special', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true"></app-card-item>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="specials">
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item class="col-auto p-2" [entity]="item" [title]="item.title || item.range" (click)="openChapter(item)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(item.id)"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('special', position, chapters.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('special', position)" [allowSelection]="true"></app-card-item>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.Storyline" *ngIf="libraryType !== LibraryType.Book && (volumes.length > 0 || chapters.length > 0)">
|
||||
@ -101,25 +106,35 @@
|
||||
<li [ngbNavItem]="TabID.Volumes" *ngIf="volumes.length > 0">
|
||||
<a ngbNavLink>{{libraryType === LibraryType.Book ? 'Books': 'Volumes'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
|
||||
<app-card-item class="col-auto p-2" [entity]="volume" [title]="volume.name" (click)="openVolume(volume)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(volume.id) + '&offset=' + coverImageOffset"
|
||||
[read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="volumes">
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item class="col-auto p-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
|
||||
[imageUrl]="imageService.getVolumeCoverImage(item.id) + '&offset=' + coverImageOffset"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('volume', position, volumes.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('volume', position)" [allowSelection]="true">
|
||||
</app-card-item>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li [ngbNavItem]="TabID.Chapters" *ngIf="chapters.length > 0">
|
||||
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<ng-container *ngFor="let chapter of chapters; let idx = index; trackBy: trackByChapterIdentity">
|
||||
<app-card-item class="col-auto p-2" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="chapter.title" (click)="openChapter(chapter)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(chapter.id) + '&offset=' + coverImageOffset"
|
||||
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
|
||||
</ng-container>
|
||||
</div>
|
||||
<app-card-detail-layout
|
||||
[isLoading]="isLoading"
|
||||
[items]="chapters">
|
||||
<ng-template #cardItem let-item let-position="idx">
|
||||
<app-card-item class="col-auto p-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
|
||||
[imageUrl]="imageService.getChapterCoverImage(item.id) + '&offset=' + coverImageOffset"
|
||||
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
|
||||
(selection)="bulkSelectionService.handleCardSelection('chapter', position, chapters.length, $event)"
|
||||
[selected]="bulkSelectionService.isCardSelected('chapter', position)" [allowSelection]="true"></app-card-item>
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -263,9 +263,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
|
||||
case(Action.Delete):
|
||||
this.deleteSeries(series);
|
||||
break;
|
||||
case(Action.Bookmarks):
|
||||
this.actionService.openBookmarkModal(series, () => this.actionInProgress = false);
|
||||
break;
|
||||
case(Action.AddToReadingList):
|
||||
this.actionService.addSeriesToReadingList(series, () => this.actionInProgress = false);
|
||||
break;
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { NavService } from 'src/app/_services/nav.service';
|
||||
|
||||
/**
|
||||
* This should go on all pages which have the side nav present and is not Settings related.
|
||||
@ -9,7 +12,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
|
||||
templateUrl: './side-nav-companion-bar.component.html',
|
||||
styleUrls: ['./side-nav-companion-bar.component.scss']
|
||||
})
|
||||
export class SideNavCompanionBarComponent implements OnInit {
|
||||
export class SideNavCompanionBarComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* If the page should show a filter
|
||||
*/
|
||||
@ -34,10 +37,26 @@ export class SideNavCompanionBarComponent implements OnInit {
|
||||
|
||||
isFilterOpen = false;
|
||||
|
||||
constructor() { }
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
constructor(private navService: NavService, private utilityService: UtilityService) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isFilterOpen = this.filterOpenByDefault;
|
||||
|
||||
// If user opens side nav while filter is open on mobile, then collapse filter (as it doesn't render well) TODO: Change this when we have new drawer
|
||||
this.navService.sideNavCollapsed$.pipe(takeUntil(this.onDestroy)).subscribe(sideNavCollapsed => {
|
||||
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop) {
|
||||
this.isFilterOpen = false;
|
||||
this.filterOpen.emit(this.isFilterOpen);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
toggleFilter() {
|
||||
|
@ -8,21 +8,22 @@
|
||||
</app-side-nav-item> -->
|
||||
|
||||
<app-side-nav-item icon="fa-home" title="Home" link="/library/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list" title="Collections" link="/collections/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series/"></app-side-nav-item>
|
||||
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10">
|
||||
<label for="filter" class="form-label visually-hidden">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
|
||||
</div>
|
||||
<app-side-nav-item icon="fa-list" title="Collections" link="/collections/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-list-ol" title="Reading Lists" link="/lists/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-bookmark" title="Bookmarks" link="/bookmarks/"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-regular fa-rectangle-list" title="All Series" link="/all-series/"></app-side-nav-item>
|
||||
<div class="mb-2 mt-3 ms-2 me-2" *ngIf="libraries.length > 10">
|
||||
<label for="filter" class="form-label visually-hidden">Filter</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
|
||||
</div>
|
||||
<app-side-nav-item *ngFor="let library of libraries | filter: filterLibrary" [link]="'/library/' + library.id + '/'"
|
||||
[icon]="utilityService.getLibraryTypeIcon(library.type)" [title]="library.name" [comparisonMethod]="'startsWith'">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="actions" [labelBy]="library.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
||||
</ng-container>
|
||||
</div>
|
||||
<app-side-nav-item *ngFor="let library of libraries | filter: filterLibrary" [link]="'/library/' + library.id + '/'"
|
||||
[icon]="utilityService.getLibraryTypeIcon(library.type)" [title]="library.name" [comparisonMethod]="'startsWith'">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="actions" [labelBy]="library.name" iconClass="fa-ellipsis-v" (actionHandler)="performAction($event, library)"></app-card-actionables>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
</div>
|
||||
<div class="side-nav-overlay" (click)="navService?.toggleSideNav()" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async)}"></div>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { filter, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { filter, map, take, takeUntil, takeWhile } from 'rxjs/operators';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
import { UtilityService } from '../../shared/_services/utility.service';
|
||||
import { Breakpoint, UtilityService } from '../../shared/_services/utility.service';
|
||||
import { Library } from '../../_models/library';
|
||||
import { User } from '../../_models/user';
|
||||
import { AccountService } from '../../_services/account.service';
|
||||
@ -29,12 +29,12 @@ export class SideNavComponent implements OnInit, OnDestroy {
|
||||
return library.name.toLowerCase().indexOf((this.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
private onDestory: Subject<void> = new Subject();
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
|
||||
constructor(public accountService: AccountService, private libraryService: LibraryService,
|
||||
public utilityService: UtilityService, private messageHub: MessageHubService,
|
||||
private actionFactoryService: ActionFactoryService, private actionService: ActionService, public navService: NavService) { }
|
||||
private actionFactoryService: ActionFactoryService, private actionService: ActionService, public navService: NavService, private router: Router) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
@ -49,16 +49,27 @@ export class SideNavComponent implements OnInit, OnDestroy {
|
||||
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
|
||||
});
|
||||
|
||||
this.messageHub.messages$.pipe(takeUntil(this.onDestory), filter(event => event.event === EVENTS.LibraryModified)).subscribe(event => {
|
||||
this.messageHub.messages$.pipe(takeUntil(this.onDestroy), filter(event => event.event === EVENTS.LibraryModified)).subscribe(event => {
|
||||
this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe((libraries: Library[]) => {
|
||||
this.libraries = libraries;
|
||||
});
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd),
|
||||
takeUntil(this.onDestroy),
|
||||
map(evt => evt as NavigationEnd))
|
||||
.subscribe((evt: NavigationEnd) => {
|
||||
if (this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop) {
|
||||
// collapse side nav
|
||||
this.navService.toggleSideNav();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestory.next();
|
||||
this.onDestory.complete();
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
handleAction(action: Action, library: Library) {
|
||||
|
@ -1,42 +0,0 @@
|
||||
<p *ngIf="series.length === 0 && !loadingBookmarks">
|
||||
There are no bookmarks. Try creating <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/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 id="series--{{series.name}}" href="javascript:void(0);" (click)="viewBookmarks(series)">{{series.name | titlecase}}</a>
|
||||
<span class="badge bg-secondary rounded-pill">{{getBookmarkPages(series.id)}}</span>
|
||||
<div class="float-end">
|
||||
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger me-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="visually-hidden">Loading...</span>
|
||||
</ng-container>
|
||||
<ng-template #notClearing>
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-secondary me-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="visually-hidden">Downloading...</span>
|
||||
</ng-container>
|
||||
<ng-template #notDownloading>
|
||||
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</button>
|
||||
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-primary me-2 btn-sm" routerLink="/library/{{series.libraryId}}/series/{{series.id}}" placement="top" ngbTooltip="Open Series" attr.aria-label="Open Series">
|
||||
<i class="fa fa-eye" title="Open Series"></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>
|
||||
|
@ -1,96 +0,0 @@
|
||||
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 */});
|
||||
}
|
||||
}
|
@ -192,9 +192,6 @@
|
||||
</ngb-panel>
|
||||
</ngb-accordion>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'bookmarks'">
|
||||
<app-series-bookmarks></app-series-bookmarks>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="tab.fragment === 'password'">
|
||||
<ng-container *ngIf="(isAdmin || hasChangePasswordRole); else noPermission">
|
||||
<p>Change your Password</p>
|
||||
|
@ -56,7 +56,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
tabs: Array<{title: string, fragment: string}> = [
|
||||
{title: 'Preferences', fragment: ''},
|
||||
{title: 'Bookmarks', fragment: 'bookmarks'},
|
||||
{title: 'Password', fragment: 'password'},
|
||||
{title: '3rd Party Clients', fragment: 'clients'},
|
||||
{title: 'Theme', fragment: 'theme'},
|
||||
|
@ -1,6 +1,5 @@
|
||||
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';
|
||||
@ -17,7 +16,6 @@ import { ColorPickerModule } from 'ngx-color-picker';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SeriesBookmarksComponent,
|
||||
UserPreferencesComponent,
|
||||
ApiKeyComponent,
|
||||
ThemeManagerComponent,
|
||||
|
Loading…
x
Reference in New Issue
Block a user