Bookmarking Pages within the Reader (#469)
# Added - Added: Added the ability to bookmark certain pages within the manga (image) reader and later download them from the series context menu. # Fixed - Fixed: Fixed an issue where after adding a new folder to an existing library, a scan wouldn't be kicked off - Fixed: In some cases, after clicking the background of a modal, the modal would close, but state wouldn't be handled as if cancel was pushed # Changed - Changed: Admin contextual actions on cards will now be separated by a line to help differentiate. - Changed: Performance enhancement on an API used before reading # Dev - Bumped dependencies to latest versions ============================================= * Bumped versions of dependencies and refactored bookmark to progress. * Refactored method names in UI from bookmark to progress to prepare for new bookmark entity * Basic code is done, user can now bookmark a page (currently image reader only). * Comments and pipes * Some accessibility for new bookmark button * Fixed up the APIs to work correctly, added a new modal to quickly explore bookmarks (not implemented, not final). * Cleanup on the UI side to get the modal to look decent * Added dismissed handlers for modals where appropriate * Refactored UI to only show number of bookmarks across files to simplify delivery. Admin actionables are now separated by hr vs non-admin actions. * Basic API implemented, now to implement the ability to actually extract files. * Implemented the ability to download bookmarks. * Fixed a bug where adding a new folder to an existing library would not trigger a scan library task. * Fixed an issue that could cause bookmarked pages to get copied out of order. * Added handler from series-card component
This commit is contained in:
parent
d1d7df9291
commit
e9ec6671d5
49 changed files with 1860 additions and 241 deletions
|
@ -0,0 +1,28 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}} Bookmarks</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<ul class="list-unstyled">
|
||||
<li class="list-group-item">
|
||||
There are {{bookmarks.length}} pages bookmarked over {{uniqueChapters}} files.
|
||||
</li>
|
||||
<li class="list-group-item" *ngIf="bookmarks.length === 0">
|
||||
No bookmarks yet
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
|
@ -0,0 +1,70 @@
|
|||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } 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';
|
||||
import { SeriesService } from 'src/app/_services/series.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, private seriesService: SeriesService) { }
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
downloadBookmarks() {
|
||||
this.isDownloading = true;
|
||||
this.downloadService.downloadBookmarks(this.bookmarks, this.series.name).pipe(take(1)).subscribe(() => {
|
||||
this.isDownloading = false;
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
7
UI/Web/src/app/_models/page-bookmark.ts
Normal file
7
UI/Web/src/app/_models/page-bookmark.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface PageBookmark {
|
||||
id: number;
|
||||
page: number;
|
||||
seriesId: number;
|
||||
volumeId: number;
|
||||
chapterId: number;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export interface Bookmark {
|
||||
export interface ProgressBookmark {
|
||||
pageNum: number;
|
||||
chapterId: number;
|
||||
bookScrollId?: string;
|
|
@ -14,14 +14,15 @@ export enum Action {
|
|||
Edit = 4,
|
||||
Info = 5,
|
||||
RefreshMetadata = 6,
|
||||
Download = 7
|
||||
Download = 7,
|
||||
Bookmarks = 8
|
||||
}
|
||||
|
||||
export interface ActionItem<T> {
|
||||
title: string;
|
||||
action: Action;
|
||||
callback: (action: Action, data: T) => void;
|
||||
|
||||
requiresAdmin: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -58,43 +59,50 @@ export class ActionFactoryService {
|
|||
this.collectionTagActions.push({
|
||||
action: Action.Edit,
|
||||
title: 'Edit',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
||||
this.seriesActions.push({
|
||||
action: Action.ScanLibrary,
|
||||
title: 'Scan Series',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
||||
this.seriesActions.push({
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'Refresh Metadata',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
||||
this.seriesActions.push({
|
||||
action: Action.Delete,
|
||||
title: 'Delete',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
||||
this.seriesActions.push({
|
||||
action: Action.Edit,
|
||||
title: 'Edit',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
||||
this.libraryActions.push({
|
||||
action: Action.ScanLibrary,
|
||||
title: 'Scan Library',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
|
||||
this.libraryActions.push({
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'Refresh Metadata',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -102,13 +110,15 @@ export class ActionFactoryService {
|
|||
this.volumeActions.push({
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
});
|
||||
|
||||
this.chapterActions.push({
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -150,12 +160,20 @@ export class ActionFactoryService {
|
|||
{
|
||||
action: Action.MarkAsRead,
|
||||
title: 'Mark as Read',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.MarkAsUnread,
|
||||
title: 'Mark as Unread',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.Bookmarks,
|
||||
title: 'Bookmarks',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -163,12 +181,14 @@ export class ActionFactoryService {
|
|||
{
|
||||
action: Action.MarkAsRead,
|
||||
title: 'Mark as Read',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.MarkAsUnread,
|
||||
title: 'Mark as Unread',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -176,25 +196,29 @@ export class ActionFactoryService {
|
|||
{
|
||||
action: Action.MarkAsRead,
|
||||
title: 'Mark as Read',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
},
|
||||
{
|
||||
action: Action.MarkAsUnread,
|
||||
title: 'Mark as Unread',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
}
|
||||
];
|
||||
|
||||
this.volumeActions.push({
|
||||
action: Action.Info,
|
||||
title: 'Info',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
});
|
||||
|
||||
this.chapterActions.push({
|
||||
action: Action.Info,
|
||||
title: 'Info',
|
||||
callback: this.dummyCallback
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { BookmarksModalComponent } from '../_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { Library } from '../_models/library';
|
||||
import { Series } from '../_models/series';
|
||||
|
@ -24,9 +26,10 @@ export type ChapterActionCallback = (chapter: Chapter) => void;
|
|||
export class ActionService implements OnDestroy {
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
private bookmarkModalRef: NgbModalRef | null = null;
|
||||
|
||||
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
|
||||
private readerService: ReaderService, private toastr: ToastrService) { }
|
||||
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal) { }
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
|
@ -153,7 +156,7 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markVolumeAsUnread(seriesId: number, volume: Volume, callback?: VolumeActionCallback) {
|
||||
forkJoin(volume.chapters?.map(chapter => this.readerService.bookmark(seriesId, volume.id, chapter.id, 0))).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
forkJoin(volume.chapters?.map(chapter => this.readerService.saveProgress(seriesId, volume.id, chapter.id, 0))).pipe(takeUntil(this.onDestroy)).subscribe(results => {
|
||||
volume.pagesRead = 0;
|
||||
volume.chapters?.forEach(c => c.pagesRead = 0);
|
||||
this.toastr.success('Marked as Unread');
|
||||
|
@ -170,7 +173,7 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markChapterAsRead(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
|
||||
this.readerService.bookmark(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
|
||||
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
|
||||
chapter.pagesRead = chapter.pages;
|
||||
this.toastr.success('Marked as Read');
|
||||
if (callback) {
|
||||
|
@ -186,7 +189,7 @@ export class ActionService implements OnDestroy {
|
|||
* @param callback Optional callback to perform actions after API completes
|
||||
*/
|
||||
markChapterAsUnread(seriesId: number, chapter: Chapter, callback?: ChapterActionCallback) {
|
||||
this.readerService.bookmark(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
|
||||
this.readerService.saveProgress(seriesId, chapter.volumeId, chapter.id, chapter.pages).pipe(take(1)).subscribe(results => {
|
||||
chapter.pagesRead = 0;
|
||||
this.toastr.success('Marked as unread');
|
||||
if (callback) {
|
||||
|
@ -195,5 +198,23 @@ 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -39,6 +39,10 @@ export class ImageService {
|
|||
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId;
|
||||
}
|
||||
|
||||
getBookmarkedImage(chapterId: number, pageNum: number) {
|
||||
return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId + '&pageNum=' + pageNum;
|
||||
}
|
||||
|
||||
updateErroredImage(event: any) {
|
||||
event.target.src = this.placeholderImage;
|
||||
}
|
||||
|
|
|
@ -43,6 +43,9 @@ export class MessageHubService {
|
|||
this.updateNotificationModalRef.closed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
this.updateNotificationModalRef.dismissed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -3,8 +3,9 @@ import { Injectable } from '@angular/core';
|
|||
import { environment } from 'src/environments/environment';
|
||||
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Bookmark } from '../_models/bookmark';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { PageBookmark } from '../_models/page-bookmark';
|
||||
import { ProgressBookmark } from '../_models/progress-bookmark';
|
||||
import { Volume } from '../_models/volume';
|
||||
|
||||
@Injectable({
|
||||
|
@ -19,20 +20,44 @@ export class ReaderService {
|
|||
|
||||
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
|
||||
|
||||
getBookmark(chapterId: number) {
|
||||
return this.httpClient.get<Bookmark>(this.baseUrl + 'reader/get-bookmark?chapterId=' + chapterId);
|
||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
|
||||
}
|
||||
|
||||
unbookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/unbookmark', {seriesId, volumeId, chapterId, page});
|
||||
}
|
||||
|
||||
getBookmarks(chapterId: number) {
|
||||
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-bookmarks?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
getBookmarksForVolume(volumeId: number) {
|
||||
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-volume-bookmarks?volumeId=' + volumeId);
|
||||
}
|
||||
|
||||
getBookmarksForSeries(seriesId: number) {
|
||||
return this.httpClient.get<PageBookmark[]>(this.baseUrl + 'reader/get-series-bookmarks?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
clearBookmarks(seriesId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/remove-bookmarks', {seriesId});
|
||||
}
|
||||
|
||||
getProgress(chapterId: number) {
|
||||
return this.httpClient.get<ProgressBookmark>(this.baseUrl + 'reader/get-progress?chapterId=' + chapterId);
|
||||
}
|
||||
|
||||
getPageUrl(chapterId: number, page: number) {
|
||||
return this.baseUrl + 'reader/image?chapterId=' + chapterId + '&page=' + page;
|
||||
}
|
||||
|
||||
getChapterInfo(chapterId: number) {
|
||||
return this.httpClient.get<ChapterInfo>(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId);
|
||||
getChapterInfo(seriesId: number, chapterId: number) {
|
||||
return this.httpClient.get<ChapterInfo>(this.baseUrl + 'reader/chapter-info?chapterId=' + chapterId + '&seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
|
||||
saveProgress(seriesId: number, volumeId: number, chapterId: number, page: number, bookScrollId: string | null = null) {
|
||||
return this.httpClient.post(this.baseUrl + 'reader/progress', {seriesId, volumeId, chapterId, pageNum: page, bookScrollId});
|
||||
}
|
||||
|
||||
markVolumeRead(seriesId: number, volumeId: number) {
|
||||
|
|
|
@ -39,6 +39,7 @@ import { RecentlyAddedComponent } from './recently-added/recently-added.componen
|
|||
import { LibraryCardComponent } from './library-card/library-card.component';
|
||||
import { SeriesCardComponent } from './series-card/series-card.component';
|
||||
import { InProgressComponent } from './in-progress/in-progress.component';
|
||||
import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component';
|
||||
|
||||
let sentryProviders: any[] = [];
|
||||
|
||||
|
@ -104,7 +105,8 @@ if (environment.production) {
|
|||
RecentlyAddedComponent,
|
||||
LibraryCardComponent,
|
||||
SeriesCardComponent,
|
||||
InProgressComponent
|
||||
InProgressComponent,
|
||||
BookmarksModalComponent
|
||||
],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
|
|
|
@ -114,15 +114,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
currentPageAnchor: string = '';
|
||||
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: [1] });
|
||||
/**
|
||||
* Last seen bookmark part path
|
||||
* Last seen progress part path
|
||||
*/
|
||||
lastSeenScrollPartPath: string = '';
|
||||
|
||||
// Temp hack: Override background color for reader and restore it onDestroy
|
||||
/**
|
||||
* Hack: Override background color for reader and restore it onDestroy
|
||||
*/
|
||||
originalBodyColor: string | undefined;
|
||||
|
||||
|
||||
|
||||
darkModeStyles = `
|
||||
*:not(input), *:not(select), *:not(code), *:not(:link), *:not(.ngx-toastr) {
|
||||
color: #dcdcdc !important;
|
||||
|
@ -198,11 +197,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
/**
|
||||
* After the page has loaded, setup the scroll handler. The scroll handler has 2 parts. One is if there are page anchors setup (aka page anchor elements linked with the
|
||||
* table of content) then we calculate what has already been reached and grab the last reached one to bookmark. If page anchors aren't setup (toc missing), then try to bookmark
|
||||
* table of content) then we calculate what has already been reached and grab the last reached one to save progress. If page anchors aren't setup (toc missing), then try to save progress
|
||||
* based on the last seen scroll part (xpath).
|
||||
*/
|
||||
ngAfterViewInit() {
|
||||
// check scroll offset and if offset is after any of the "id" markers, bookmark it
|
||||
// check scroll offset and if offset is after any of the "id" markers, save progress
|
||||
fromEvent(window, 'scroll')
|
||||
.pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => {
|
||||
if (this.isLoading) return;
|
||||
|
@ -215,7 +214,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset);
|
||||
if (alreadyReached.length > 0) {
|
||||
this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1];
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
return;
|
||||
} else {
|
||||
this.currentPageAnchor = '';
|
||||
|
@ -223,7 +222,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
}
|
||||
|
||||
if (this.lastSeenScrollPartPath !== '') {
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -279,7 +278,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
forkJoin({
|
||||
chapter: this.seriesService.getChapter(this.chapterId),
|
||||
bookmark: this.readerService.getBookmark(this.chapterId),
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
chapters: this.bookService.getBookChapters(this.chapterId),
|
||||
info: this.bookService.getBookInfo(this.chapterId)
|
||||
}).pipe(take(1)).subscribe(results => {
|
||||
|
@ -287,17 +286,17 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.volumeId = results.chapter.volumeId;
|
||||
this.maxPages = results.chapter.pages;
|
||||
this.chapters = results.chapters;
|
||||
this.pageNum = results.bookmark.pageNum;
|
||||
this.pageNum = results.progress.pageNum;
|
||||
this.bookTitle = results.info;
|
||||
|
||||
|
||||
if (this.pageNum >= this.maxPages) {
|
||||
this.pageNum = this.maxPages - 1;
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
|
||||
// Check if user bookmark has part, if so load it so we scroll to it
|
||||
this.loadPage(results.bookmark.bookScrollId || undefined);
|
||||
// Check if user progress has part, if so load it so we scroll to it
|
||||
this.loadPage(results.progress.bookScrollId || undefined);
|
||||
}, () => {
|
||||
setTimeout(() => {
|
||||
this.closeReader();
|
||||
|
@ -484,7 +483,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
loadPage(part?: string | undefined, scrollTop?: number | undefined) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
|
||||
this.bookService.getBookPage(this.chapterId, this.pageNum).pipe(take(1)).subscribe(content => {
|
||||
this.page = this.domSanitizer.bypassSecurityTrustHtml(content);
|
||||
|
|
|
@ -12,6 +12,9 @@
|
|||
{{subtitle}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-left: auto; padding-right: 3%;">
|
||||
<button class="btn btn-icon btn-small" role="checkbox" [attr.aria-checked]="pageBookmarked" title="{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()"><i class="{{pageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i><span class="sr-only">{{pageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="isLoading">
|
||||
|
|
|
@ -183,6 +183,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
* If extended settings area is visible. Blocks auto-closing of menu.
|
||||
*/
|
||||
settingsOpen: boolean = false;
|
||||
/**
|
||||
* A map of bookmarked pages to anything. Used for O(1) lookup time if a page is bookmarked or not.
|
||||
*/
|
||||
bookmarks: {[key: string]: number} = {};
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
|
@ -191,6 +195,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
|
||||
|
||||
get pageBookmarked() {
|
||||
return this.bookmarks.hasOwnProperty(this.pageNum);
|
||||
}
|
||||
|
||||
|
||||
get splitIconClass() {
|
||||
|
@ -348,13 +355,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.pageNum = 1;
|
||||
|
||||
forkJoin({
|
||||
bookmark: this.readerService.getBookmark(this.chapterId),
|
||||
chapterInfo: this.readerService.getChapterInfo(this.chapterId)
|
||||
progress: this.readerService.getProgress(this.chapterId),
|
||||
chapterInfo: this.readerService.getChapterInfo(this.seriesId, this.chapterId),
|
||||
bookmarks: this.readerService.getBookmarks(this.chapterId)
|
||||
}).pipe(take(1)).subscribe(results => {
|
||||
this.volumeId = results.chapterInfo.volumeId;
|
||||
this.maxPages = results.chapterInfo.pages;
|
||||
|
||||
let page = results.bookmark.pageNum;
|
||||
let page = results.progress.pageNum;
|
||||
if (page >= this.maxPages) {
|
||||
page = this.maxPages - 1;
|
||||
}
|
||||
|
@ -367,6 +375,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
this.updateTitle(results.chapterInfo);
|
||||
|
||||
// From bookmarks, create map of pages to make lookup time O(1)
|
||||
this.bookmarks = {};
|
||||
results.bookmarks.forEach(bookmark => {
|
||||
this.bookmarks[bookmark.page] = 1;
|
||||
});
|
||||
|
||||
this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId).pipe(take(1)).subscribe(chapterId => {
|
||||
this.nextChapterId = chapterId;
|
||||
if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
|
||||
|
@ -747,14 +761,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
loadPage() {
|
||||
if (!this.canvas || !this.ctx) { return; }
|
||||
|
||||
// Due to the fact that we start at image 0, but page 1, we need the last page to be bookmarked as page + 1 to be completed
|
||||
// Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
|
||||
let pageNum = this.pageNum;
|
||||
if (this.pageNum == this.maxPages - 1) {
|
||||
pageNum = this.pageNum + 1;
|
||||
}
|
||||
|
||||
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
|
||||
this.isLoading = true;
|
||||
this.canvasImage = this.cachedImages.current();
|
||||
|
@ -814,13 +828,13 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
if (this.pageNum >= this.maxPages - 10) {
|
||||
// Tell server to cache the next chapter
|
||||
if (this.nextChapterId > 0 && !this.nextChapterPrefetched) {
|
||||
this.readerService.getChapterInfo(this.nextChapterId).pipe(take(1)).subscribe(res => {
|
||||
this.readerService.getChapterInfo(this.seriesId, this.nextChapterId).pipe(take(1)).subscribe(res => {
|
||||
this.nextChapterPrefetched = true;
|
||||
});
|
||||
}
|
||||
} else if (this.pageNum <= 10) {
|
||||
if (this.prevChapterId > 0 && !this.prevChapterPrefetched) {
|
||||
this.readerService.getChapterInfo(this.prevChapterId).pipe(take(1)).subscribe(res => {
|
||||
this.readerService.getChapterInfo(this.seriesId, this.prevChapterId).pipe(take(1)).subscribe(res => {
|
||||
this.prevChapterPrefetched = true;
|
||||
});
|
||||
}
|
||||
|
@ -905,7 +919,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
handleWebtoonPageChange(updatedPageNum: number) {
|
||||
this.setPageNum(updatedPageNum);
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum).pipe(take(1)).subscribe(() => {/* No operation */});
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
|
@ -945,4 +959,22 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
|
||||
this.updateForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmarks the current page for the chapter
|
||||
*/
|
||||
bookmarkPage() {
|
||||
const pageNum = this.pageNum;
|
||||
if (this.pageBookmarked) {
|
||||
// Remove bookmark
|
||||
this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
|
||||
delete this.bookmarks[pageNum];
|
||||
});
|
||||
} else {
|
||||
this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum).pipe(take(1)).subscribe(() => {
|
||||
this.bookmarks[pageNum] = 1;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { ImageService } from 'src/app/_services/image.service';
|
|||
import { ActionFactoryService, Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { ConfirmService } from '../shared/confirm.service';
|
||||
import { ActionService } from '../_services/action.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-card',
|
||||
|
@ -30,7 +31,8 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
constructor(private accountService: AccountService, private router: Router,
|
||||
private seriesService: SeriesService, private toastr: ToastrService,
|
||||
private modalService: NgbModal, private confirmService: ConfirmService,
|
||||
public imageService: ImageService, private actionFactoryService: ActionFactoryService) {
|
||||
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
|
||||
private actionService: ActionService) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
|
@ -68,6 +70,9 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
|||
case(Action.Edit):
|
||||
this.openEditModal(series);
|
||||
break;
|
||||
case(Action.Bookmarks):
|
||||
this.actionService.openBookmarkModal(series, (series) => {/* No Operation */ });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -13,12 +13,11 @@
|
|||
</div>
|
||||
<div class="row no-gutters">
|
||||
<div>
|
||||
<button class="btn btn-primary" (click)="read()" (mouseover)="showBook = true;" (mouseleave)="showBook = false;" [disabled]="isLoading">
|
||||
<button class="btn btn-primary" (click)="read()" [disabled]="isLoading">
|
||||
<span>
|
||||
<i class="fa {{showBook ? 'fa-book-open' : 'fa-book'}}"></i>
|
||||
</span>
|
||||
|
||||
<span class="read-btn--text">{{(hasReadingProgress) ? 'Continue' : 'Read'}}</span>
|
||||
<span class="read-btn--text"> {{(hasReadingProgress) ? 'Continue' : 'Read'}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="ml-2" *ngIf="isAdmin">
|
||||
|
|
|
@ -150,6 +150,9 @@ export class SeriesDetailComponent implements OnInit {
|
|||
case(Action.Delete):
|
||||
this.deleteSeries(series);
|
||||
break;
|
||||
case(Action.Bookmarks):
|
||||
this.actionService.openBookmarkModal(series, (series) => this.actionInProgress = false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import { saveAs } from 'file-saver';
|
|||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { PageBookmark } from 'src/app/_models/page-bookmark';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
|
@ -85,6 +87,12 @@ export class DownloadService {
|
|||
});
|
||||
}
|
||||
|
||||
downloadBookmarks(bookmarks: PageBookmark[], seriesName: string) {
|
||||
return this.httpClient.post(this.baseUrl + 'download/bookmarks', {bookmarks}, {observe: 'response', responseType: 'blob' as 'text'}).pipe(take(1), map(resp => {
|
||||
this.preformSave(resp.body || '', this.getFilenameFromHeader(resp.headers, seriesName));
|
||||
}));
|
||||
}
|
||||
|
||||
private preformSave(res: string, filename: string) {
|
||||
const blob = new Blob([res], {type: 'text/plain;charset=utf-8'});
|
||||
saveAs(blob, filename);
|
||||
|
|
|
@ -2,7 +2,9 @@
|
|||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
|
||||
<button ngbDropdownItem *ngFor="let action of actions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
<button ngbDropdownItem *ngFor="let action of nonAdminActions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
<div class="dropdown-divider" *ngIf="nonAdminActions.length > 1 && adminActions.length > 1"></div>
|
||||
<button ngbDropdownItem *ngFor="let action of adminActions" (click)="performAction($event, action)">{{action.title}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
|
@ -15,9 +15,15 @@ export class CardActionablesComponent implements OnInit {
|
|||
@Input() disabled: boolean = false;
|
||||
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
|
||||
|
||||
adminActions: ActionItem<any>[] = [];
|
||||
nonAdminActions: ActionItem<any>[] = [];
|
||||
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.nonAdminActions = this.actions.filter(item => !item.requiresAdmin);
|
||||
this.adminActions = this.actions.filter(item => item.requiresAdmin);
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
|
@ -33,4 +39,7 @@ export class CardActionablesComponent implements OnInit {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: Insert hr to separate admin actions
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
|
||||
import { ConfirmConfig } from './confirm-dialog/_models/confirm-config';
|
||||
|
||||
|
@ -36,9 +37,12 @@ export class ConfirmService {
|
|||
|
||||
const modalRef = this.modalService.open(ConfirmDialogComponent);
|
||||
modalRef.componentInstance.config = config;
|
||||
modalRef.closed.subscribe(result => {
|
||||
modalRef.closed.pipe(take(1)).subscribe(result => {
|
||||
return resolve(result);
|
||||
});
|
||||
modalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
return reject(false);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
@ -57,9 +61,12 @@ export class ConfirmService {
|
|||
|
||||
const modalRef = this.modalService.open(ConfirmDialogComponent);
|
||||
modalRef.componentInstance.config = config;
|
||||
modalRef.closed.subscribe(result => {
|
||||
modalRef.closed.pipe(take(1)).subscribe(result => {
|
||||
return resolve(result);
|
||||
});
|
||||
modalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
return reject(false);
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,9 +46,10 @@ body {
|
|||
font-family: "EBGaramond", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
// .disabled, :disabled {
|
||||
// cursor: not-allowed !important;
|
||||
// }
|
||||
|
||||
.btn-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Slider handle override
|
||||
::ng-deep {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue