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:
Joseph Milazzo 2022-04-23 13:58:14 -05:00 committed by GitHub
parent 62715a9977
commit 9d6843614d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 648 additions and 634 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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&nbsp;<i class="fa fa-external-link-alt" aria-hidden="true"></i></a>.
</ng-template>
</app-card-detail-layout>

View 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 */});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&nbsp;<i class="fa fa-external-link-alt" aria-hidden="true"></i></a>.
</p>
<ul class="list-group">
<li *ngFor="let series of series" class="list-group-item">
<div>
<h4>
<a id="series--{{series.name}}" href="javascript:void(0);" (click)="viewBookmarks(series)">{{series.name | titlecase}}</a>
&nbsp;<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>

View File

@ -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 */});
}
}

View File

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

View File

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

View File

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