Infinite Scroll + List View + Cover Upload Redesign (#1319)

* Started with the redesign of the cover image chooser redesign to be less click intensive for volume/chapter images.

Made some headings bold in card detail drawer.

* Tweaked the styles

* Moved where the info cards show

* Added an ability to open a page settings drawer

* Cleaned up some old code that isn't needed anymore.

* Started implementing a list view. Refactored some title code to a dedicated component

* List view implemented but way too many API calls. Either need caching or adjusting the SeriesDetail api.

* Fixed a bug where if the progress bar didn't render on a card item while a download was in progress, the download indicator would be removed.

* Large refactor to move a lot of the needed fields to the chapter and volume dtos for series detail. All fields are noted when only used in series detail.

* Implemented cards for other tabs (except related)

* Fixed the unit test which needed a mocked reader service call.

* More cleanup around age rating and removing old code from the refactor. Commented out sorting till i feel motivated to work on that.

* Some cleanup and restored cards as initial layout. Time to test this out and see if there is value add.

* Added ability for Chapters tab to show the volume chapters belong to (if applicable)

* Adding style fixes

* Cover image updates, don't allow the first image (which is what is currently set) to respond to cover changes.

Hide the ID field on list item for series detail.

* Refactored the title for list item to be injectable

* Cleaned up the selection code to make it less finicky on mobile when tap scrolling.

* Refactored chapter tab to show volume as well on list view.

* Ensure word count shows for Volumes

* Started adding virtual scrolling, pushing up so Robbie can mess around

* Started adding virtual scrolling, pushing up so Robbie can mess around

* Fixed a bug where all chapters would come under specials

* Show title data as accent if set.

* Style fixes for virtual scroller

* Restyling scroll

* Implemented a way to show storyline with virtual scrolling

* Show Word Count for chapters and cleaned up some logics.

* I might have card layout working with virtual scroll code.

* Some cleanup to hide more system like properties from info bar on series detail page. Fixed some missing time estimate info on storyline chapters.

* Fixed a regression on series service when I integrated VolumeTitle.

* Refactored read time to the backend. Added WordCount to the volume itself so we don't need to calculate on frontend. When asking to analyze files from a series, force the calculation.

* Fixed SeriesDetail api code

* Fixed up the code in the drawer to better update list/card mode

* Basic infinite scroll implemented, however due to how we are updating the list to render, we are re-rending cards that haven't been touched.

* Updated how we render and layout data for infinite scroll on library detail. It's almost there.

* Started laying foundation for loading pages backwards.

Removed lazy loading of images since we are now using virtual paging.

* Hooked in some basic code to allow user to load a prev page with infinite scroll.

* Fixed up series detail api and undid the non-lazy loaded images.

Changed the router to help with this infinite loading on Firefox issue.

* Fixed up some naming issues with Series Detail and added a new test.

* This is an infinite scroll without pagination implementation. It is not fully done, but off to a good start. Virtual scroller with jump bar is working pretty well, def needs more polishing and tweaking. There are hacks in this implementation that need to be revisited.

* Refactored code so that we don't use any pagination and load all results by default.

* Misc code cleanup from build warnings.

* Cleaned up some logic for how to display titles in list view.

* More title cleanup for specials

* Hooked up page layout to user preferences and renamed an existing user pref name to match the dto.

* Swapped out everything but storyline with virtual-scroller over CDK

* Removed CDK from series detail.

* Default value for migration on page layout

* Updating card layout for library detail page

* fixing height for mobile

* Moved scrollbar

* Tweaked some styling for layouts when there is no data

* Refactored the series cards into their own component to make it re-usable.

* More tweaks on series info cards layout and enhanced a few pages with trackby functions.

* Removed some dead code

* Added download on series detail to actionables to fit in with new scroll strategy.

* Fixed language not being updated and sent to the backend for series update.

* Fixed a bad migration (if you ran any prior migration in this branch, you need to undo before you use this commit)

* Adding sticky tabs

* fixed mobile gap on sticky tab

* Enhanced the card title for books to show number up front.

* Adjusted the gutters on admin dashboard

* Removed debug code

* Removing duplicate book title

* Cleaned up old references to cdk scroller

* Implemented a basic jump bar scaling algorithm. Not perfect, but works pretty well.

* Code smells

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-06-13 16:37:49 -05:00 committed by GitHub
parent f0f0e23e88
commit bbc48a5f5b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
122 changed files with 7863 additions and 2097 deletions

1859
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@
"@angular/platform-browser-dynamic": "~13.2.2",
"@angular/router": "~13.2.2",
"@fortawesome/fontawesome-free": "^6.0.0",
"@iharbeck/ngx-virtual-scroller": "^13.0.4",
"@microsoft/signalr": "^6.0.2",
"@ng-bootstrap/ng-bootstrap": "^12.1.2",
"@popperjs/core": "^2.11.2",

View file

@ -1,4 +1,7 @@
import { HourEstimateRange } from './hour-estimate-range';
import { MangaFile } from './manga-file';
import { AgeRating } from './metadata/age-rating';
import { AgeRatingDto } from './metadata/age-rating-dto';
/**
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
@ -23,4 +26,19 @@ export interface Chapter {
* Actual name of the Chapter if populated in underlying metadata
*/
titleName: string;
/**
* Summary for the chapter
*/
summary?: string;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
ageRating: AgeRating;
releaseDate: string;
wordCount: number;
/**
* 'Volume number'. Only available for SeriesDetail
*/
volumeTitle?: string;
}

View file

@ -2,5 +2,5 @@ export interface HourEstimateRange{
minHours: number;
maxHours: number;
avgHours: number;
hasProgress: boolean;
//hasProgress: boolean;
}

View file

@ -0,0 +1,10 @@
export enum PageLayoutMode {
/**
* Use Cards for laying out data
*/
Cards = 0,
/**
* Use list style for laying out items
*/
List = 1
}

View file

@ -1,6 +1,7 @@
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
import { BookPageLayoutMode } from '../book-page-layout-mode';
import { PageLayoutMode } from '../page-layout-mode';
import { PageSplitOption } from './page-split-option';
import { ReaderMode } from './reader-mode';
import { ReadingDirection } from './reading-direction';
@ -31,6 +32,7 @@ export interface Preferences {
// Global
theme: SiteTheme;
globalPageLayoutMode: PageLayoutMode;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
@ -39,3 +41,4 @@ export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.
export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}];
export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}];
export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}];
export const pageLayoutModes = [{text: 'Cards', value: PageLayoutMode.Cards}, {text: 'List', value: PageLayoutMode.List}];

View file

@ -52,4 +52,7 @@ export interface Series {
* Number of words in the series
*/
wordCount: number;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
}

View file

@ -1,4 +1,5 @@
import { Chapter } from './chapter';
import { HourEstimateRange } from './hour-estimate-range';
export interface Volume {
id: number;
@ -8,5 +9,12 @@ export interface Volume {
lastModified: string;
pages: number;
pagesRead: number;
chapters: Array<Chapter>; // TODO: Validate any cases where this is undefined
chapters: Array<Chapter>;
/**
* This is only available on the object when fetched for SeriesDetail
*/
timeEstimate?: HourEstimateRange;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
}

View file

@ -2,7 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import { finalize, take, takeWhile } from 'rxjs/operators';
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';
@ -527,5 +527,4 @@ export class ActionService implements OnDestroy {
}
});
}
}

View file

@ -67,6 +67,10 @@ export enum EVENTS {
* When bulk bookmarks are being converted
*/
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
/**
* When files are being scanned to calculate word count
*/
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress'
}
export interface Message<T> {
@ -155,6 +159,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.WordCountAnalyzerProgress, resp => {
this.messagesSource.next({
event: EVENTS.WordCountAnalyzerProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.LibraryModified, resp => {
this.messagesSource.next({
event: EVENTS.LibraryModified,

View file

@ -22,7 +22,7 @@ export class MetadataService {
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
constructor(private httpClient: HttpClient) { }
getAgeRating(ageRating: AgeRating) {
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {

View file

@ -9,11 +9,6 @@ import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
import { PageBookmark } from '../_models/page-bookmark';
import { ProgressBookmark } from '../_models/progress-bookmark';
export const MAX_WORDS_PER_HOUR = 30_000;
export const MIN_WORDS_PER_HOUR = 10_260;
export const MAX_PAGES_PER_MINUTE = 2.75;
export const MIN_PAGES_PER_MINUTE = 3.33;
@Injectable({
providedIn: 'root'
})
@ -129,18 +124,11 @@ export class ReaderService {
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
}
// TODO: Cache this information
getTimeLeft(seriesId: number) {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
}
getTimeToRead(seriesId: number) {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/read-time?seriesId=' + seriesId);
}
getManualTimeToRead(wordCount: number, pageCount: number, isEpub: boolean) {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/manual-read-time?wordCount=' + wordCount + '&pageCount=' + pageCount + '&isEpub=' + isEpub);
}
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/

View file

@ -3,7 +3,7 @@
Admin Dashboard
</h2>
</app-side-nav-companion-bar>
<div class="container-fluid">
<div class="container-fluid g-0">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-tabs">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>

View file

@ -8,6 +8,7 @@
<app-card-detail-layout
[isLoading]="loadingSeries"
[items]="series"
[trackByIdentity]="trackByIdentity"
[pagination]="pagination"
[filterSettings]="filterSettings"
[filterOpen]="filterOpen"

View file

@ -131,5 +131,5 @@ export class AllSeriesComponent implements OnInit, OnDestroy {
this.loadPage();
}
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`;
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
}

View file

@ -71,8 +71,9 @@ const routes: Routes = [
]
},
{path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)},
//{path: '', pathMatch: 'full', redirectTo: 'login'}, // This shouldn't be needed
{path: '**', pathMatch: 'full', redirectTo: 'libraries'},
{path: '', pathMatch: 'full', redirectTo: 'login'},
{path: '**', pathMatch: 'prefix', redirectTo: 'libraries'},
];
@NgModule({

View file

@ -33,6 +33,7 @@ export class AppComponent implements OnInit {
this.ngbModal.dismissAll();
}
});
}
@HostListener('window:resize', ['$event'])

View file

@ -122,7 +122,7 @@
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col2">2 Column</label>
</div>
</div>
</div>

View file

@ -7,7 +7,9 @@
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<app-card-detail-layout
[isLoading]="loadingBookmarks"
[items]="series">
[items]="series"
[trackByIdentity]="trackByIdentity"
>
<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"

View file

@ -28,6 +28,8 @@ export class BookmarksComponent implements OnInit, OnDestroy {
clearingSeries: {[id: number]: boolean} = {};
actions: ActionItem<Series>[] = [];
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
private onDestroy: Subject<void> = new Subject<void>();
constructor(private readerService: ReaderService, private seriesService: SeriesService,

View file

@ -338,11 +338,13 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
return a.isoCode == b.isoCode;
}
if (this.metadata.language) {
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
if (this.metadata.language === undefined || this.metadata.language === null || this.metadata.language === '') {
this.metadata.language = 'en';
}
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
if (l !== undefined) {
this.languageSettings.savedData = l;
}
return of(true);
}
@ -428,6 +430,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
model.nameLocked = this.series.nameLocked;
model.sortNameLocked = this.series.sortNameLocked;
model.localizedNameLocked = this.series.localizedNameLocked;
model.language = this.metadata.language;
apis.push(this.seriesService.updateSeries(model));
}
@ -459,8 +462,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
this.metadata.genres = genres;
}
updateLanguage(language: Language) {
this.metadata.language = language.isoCode;
updateLanguage(language: Array<Language>) {
if (language.length === 0) {
this.metadata.language = '';
return;
}
this.metadata.language = language[0].isoCode;
}
updatePerson(persons: Person[], role: PersonRole) {

View file

@ -1,27 +0,0 @@
<div class="card" *ngIf="bookmark != undefined">
<app-image height="230px" width="170px" [imageUrl]="imageService.getBookmarkedImage(bookmark.chapterId, bookmark.page)"></app-image>
<div class="card-body" *ngIf="bookmark.page >= 0">
<div class="header-row">
<span class="card-title" tabindex="0">
Page {{bookmark.page + 1}}
</span>
<span class="card-actions float-end" *ngIf="series != undefined">
<button attr.aria-labelledby="series--{{series.name}}" class="btn btn-danger btn-sm" (click)="removeBookmark()"
[disabled]="isClearing" placement="top" ngbTooltip="Remove Bookmark" attr.aria-label="Remove Bookmark">
<ng-container *ngIf="isClearing; 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>
</span>
</div>
<div>
<a *ngIf="series != undefined" class="title-overflow library" href="/library/{{series.libraryId}}/series/{{series.id}}"
placement="top" id="bookmark_card_{{series.name}}_{{bookmark.id}}" [ngbTooltip]="series.name | titlecase">{{series.name | titlecase}}</a>
</div>
</div>
</div>

View file

@ -1,25 +0,0 @@
.card-body {
padding: 5px;
}
.card {
margin-left: 5px;
margin-right: 5px;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-overflow {
font-size: 13px;
width: 130px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
margin-top: 2px;
margin-bottom: 0px;
}

View file

@ -1,43 +0,0 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
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';
import { PageBookmark } from '../../_models/page-bookmark';
@Component({
selector: 'app-bookmark',
templateUrl: './bookmark.component.html',
styleUrls: ['./bookmark.component.scss']
})
export class BookmarkComponent implements OnInit {
@Input() bookmark: PageBookmark | undefined;
@Output() bookmarkRemoved: EventEmitter<PageBookmark> = new EventEmitter<PageBookmark>();
series: Series | undefined;
isClearing: boolean = false;
isDownloading: boolean = false;
constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { }
ngOnInit(): void {
if (this.bookmark) {
this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => {
this.series = series;
});
}
}
handleClick(event: any) {
}
removeBookmark() {
if (this.bookmark === undefined) return;
this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => {
this.bookmarkRemoved.emit(this.bookmark);
this.bookmark = undefined;
});
}
}

View file

@ -21,7 +21,7 @@ export class BulkOperationsComponent implements OnInit {
ngOnInit(): void {
const navBar = document.querySelector('.navbar');
if (navBar) {
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height);
this.topOffset = Math.ceil(navBar.getBoundingClientRect().height); // TODO: We can make this fixed 63px
}
}

View file

@ -1,32 +1,8 @@
<div class="offcanvas-header">
<h5 class="offcanvas-title">
<ng-container [ngSwitch]="libraryType">
<ng-container *ngSwitchCase="LibraryType.Comic">
<span class="modal-title" id="modal-basic-title">
<ng-container *ngIf="chapter.titleName != ''; else fullComicTitle">
{{chapter.titleName}}
</ng-container>
<ng-template #fullComicTitle>
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Chapter ' : 'Volume ') + data.number : 'Special'}}
</ng-template>
</span>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Manga">
<span class="modal-title" id="modal-basic-title">
<ng-container *ngIf="chapter.titleName != ''; else fullMangaTitle">
{{chapter.titleName}}
</ng-container>
<ng-template #fullMangaTitle>
{{parentName}} - {{data.number != 0 ? (isChapter ? 'Issue #' : 'Volume ') + data.number : 'Special'}}
</ng-template>
</span>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Book">
<span class="modal-title" id="modal-basic-title">
{{chapter.titleName}}
</span>
</ng-container>
</ng-container>
<span class="modal-title" id="modal-basic-title">
<app-entity-title [libraryType]="libraryType" [entity]="data" [seriesName]="parentName"></app-entity-title>
</span>
</h5>
<button type="button" class="btn-close text-reset" aria-label="Close" (click)="activeOffcanvas.dismiss()"></button>
</div>
@ -44,7 +20,7 @@
<app-image class="me-2" width="74px" [imageUrl]="chapter.coverImage"></app-image>
</div>
<div class="col-md-10 col-lg-11">
<ng-container *ngIf="summary$ | async as summary; else noSummary">
<ng-container *ngIf="summary.length > 0; else noSummary">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</ng-container>
<ng-template #noSummary>
@ -53,73 +29,8 @@
</div>
</div>
<div class="row g-0 mt-4 mb-3">
<ng-container *ngIf="totalPages > 0">
<div class="col-auto mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
{{totalPages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<app-entity-info-cards [entity]="data"></app-entity-info-cards>
<ng-container *ngIf="chapterMetadata !== undefined && chapterMetadata.releaseDate && (chapterMetadata.releaseDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title label="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
{{chapterMetadata.releaseDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && chapterMetadata !== undefined && chapterMetadata.wordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && chapterMetadata !== undefined && chapterMetadata.wordCount > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{chapterMetadata.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapterMetadata !== undefined">
<ng-container *ngIf="ageRating !== '' && ageRating !== 'Unknown'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Age Rating" [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
{{ageRating}}
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
<ng-container *ngIf="chapter.created && chapter.created !== '' && (chapter.created | date: 'shortDate') !== '1/1/01'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
{{chapter.created | date:'short' || '-'}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container>
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="ID" [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
{{data.id}}
</app-icon-and-title>
</div>
</ng-container>
</div>
<!-- 2 rows to show some tags-->
<ng-container *ngIf="chapterMetadata !== undefined">
@ -187,18 +98,13 @@
<li [ngbNavItem]="tabs[TabID.Cover]">
<a ngbNavLink>{{tabs[TabID.Cover].title}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="chapter.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
<div class="row g-0">
<button class="btn btn-primary flex-end mb-2" [disabled]="coverImageSaveLoading" (click)="saveCoverImage()">
<ng-container *ngIf="coverImageSaveLoading; else notSaving">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">Loading...</span>
</ng-container>
<ng-template #notSaving>
Save
</ng-template>
</button>
</div>
<app-cover-image-chooser [(imageUrls)]="imageUrls"
[showReset]="chapter.coverImageLocked"
[showApplyButton]="true"
(applyCover)="applyCoverImage($event)"
(resetCover)="resetCoverImage()"
>
</app-cover-image-chooser>
</ng-template>
</li>

View file

@ -14,3 +14,7 @@
overflow: auto;
height: calc(40vh - 63px); // drawer height - offcanvas heading height
}
.h6 {
font-weight: 600;
}

View file

@ -2,7 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { NgbActiveOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { finalize, Observable, of, take, takeWhile, tap } from 'rxjs';
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
@ -50,21 +50,6 @@ export class CardDetailDrawerComponent implements OnInit {
isChapter = false;
chapters: Chapter[] = [];
/**
* If a cover image update occured.
*/
coverImageUpdate: boolean = false;
coverImageIndex: number = 0;
/**
* Url of the selected cover
*/
selectedCover: string = '';
coverImageLocked: boolean = false;
/**
* When the API is doing work
*/
coverImageSaveLoading: boolean = false;
imageUrls: Array<string> = [];
@ -77,16 +62,7 @@ export class CardDetailDrawerComponent implements OnInit {
active = this.tabs[0];
chapterMetadata!: ChapterMetadata;
ageRating!: string;
summary$: Observable<string> = of('');
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
minHoursToRead: number = 1;
maxHoursToRead: number = 1;
/**
* We use a separate variable because if this is a volume, we need a sum of all chapters
*/
totalPages: number = 0;
summary: string = '';
download$: Observable<Download> | null = null;
downloadInProgress: boolean = false;
@ -129,25 +105,14 @@ export class CardDetailDrawerComponent implements OnInit {
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
this.metadataService.getAgeRating(this.chapterMetadata.ageRating).subscribe(ageRating => this.ageRating = ageRating);
this.totalPages = this.chapter.pages;
if (!this.isChapter) {
// Need to account for multiple chapters if this is a volume
this.totalPages = this.utilityService.asVolume(this.data).chapters.map(c => c.pages).reduce((sum, d) => sum + d);
}
this.readerService.getManualTimeToRead(this.chapterMetadata.wordCount, this.totalPages, this.chapter.files[0].format === MangaFormat.EPUB).subscribe((time) => this.readingTime = time);
});
if (this.isChapter) {
this.summary$ = this.metadataService.getChapterSummary(this.data.id);
this.summary = this.utilityService.asChapter(this.data).summary || '';
} else {
this.summary$ = this.metadataService.getChapterSummary(this.utilityService.asVolume(this.data).chapters[0].id);
this.summary = this.utilityService.asVolume(this.data).chapters[0].summary || '';
}
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
@ -180,7 +145,7 @@ export class CardDetailDrawerComponent implements OnInit {
}
close() {
this.activeOffcanvas.close({coverImageUpdate: this.coverImageUpdate});
this.activeOffcanvas.close();
}
formatChapterNumber(chapter: Chapter) {
@ -196,36 +161,14 @@ export class CardDetailDrawerComponent implements OnInit {
}
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
applyCoverImage(coverUrl: string) {
this.uploadService.updateChapterCoverImage(this.chapter.id, coverUrl).subscribe(() => {});
}
updateSelectedImage(url: string) {
this.selectedCover = url;
}
handleReset() {
this.coverImageLocked = false;
}
saveCoverImage() {
this.coverImageSaveLoading = true;
const selectedIndex = this.coverImageIndex || 0;
if (selectedIndex > 0) {
this.uploadService.updateChapterCoverImage(this.chapter.id, this.selectedCover).subscribe(() => {
if (this.coverImageIndex > 0) {
this.chapter.coverImageLocked = true;
this.coverImageUpdate = true;
}
this.coverImageSaveLoading = false;
}, err => this.coverImageSaveLoading = false);
} else if (this.coverImageLocked === false) {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info('Cover image reset');
this.coverImageSaveLoading = false;
this.coverImageUpdate = true;
});
}
resetCoverImage() {
this.uploadService.resetChapterCoverLock(this.chapter.id).subscribe(() => {
this.toastr.info('A job has been enqueued to regenerate the cover image');
});
}
markChapterAsRead(chapter: Chapter) {
@ -292,13 +235,10 @@ export class CardDetailDrawerComponent implements OnInit {
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
console.log('want to download: ', wantToDownload);
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
tap(val => {
console.log(val);
}),
takeWhile(val => {
return val.state != 'DONE';
}),

View file

@ -13,29 +13,39 @@
</div>
</div>
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
<div class="viewport-container">
<div class="viewport-container" #scrollingBlock>
<div class="content-container">
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<div class="card-container mt-2 mb-2">
<ng-container [ngTemplateOutlet]="cardTemplate"></ng-container>
<virtual-scroller #scroll [items]="items" (vsEnd)="fetchMore($event)" [bufferAmount]="1">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>
</virtual-scroller>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</div>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
</div>
<ng-container *ngIf="pagination && items.length > 0 && pagination.totalPages > 1" [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
<ng-container [ngTemplateOutlet]="jumpBar" [ngTemplateOutletContext]="{ id: 'jumpbar' }"></ng-container>
</div>
<ng-template #cardTemplate>
<div class="grid row g-0" >
<div class="card col-auto mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>
</virtual-scroller>
<div class="mx-auto" *ngIf="items.length === 0 && !isLoading" style="width: 200px;">
<p><ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container></p>
</div>
<p *ngIf="items.length === 0 && !isLoading">
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</ng-template>
<ng-template #paginationTemplate let-id="id">
@ -93,8 +103,8 @@
<ng-template #jumpBar>
<div class="jump-bar">
<ng-container *ngFor="let jumpKey of jumpBarKeys; let i = index;">
<button class="btn btn-link {{i % 2 !== 0 ? 'd-lg-flex' : 'd-md-flex'}}" (click)="scrollTo(jumpKey)">
<ng-container *ngFor="let jumpKey of jumpBarKeysToRender; let i = index;">
<button class="btn btn-link" (click)="scrollTo(jumpKey)">
{{jumpKey.title}}
</button>
</ng-container>

View file

@ -17,7 +17,7 @@
.card-container {
display: inline-block;
width: 100%;
overflow-y: auto;
//overflow-y: auto;
}
.grid {
@ -73,3 +73,12 @@
}
}
}
.virtual-scroller, virtual-scroller {
width: 100%;
//height: calc(100vh - 160px); // 64 is a random number, 523 for me.
height: calc(var(--vh) * 100 - 160px);
//height: calc(100vh - 160px);
//background-color: red;
//max-height: calc(var(--vh)*100 - 170px);
}

View file

@ -1,28 +1,33 @@
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { DOCUMENT } from '@angular/common';
import { AfterViewInit, Component, ContentChild, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { from, Subject } from 'rxjs';
import { AfterViewInit, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, NgZone, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
import { IPageInfo, VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
import { filter, from, map, pairwise, Subject, tap, throttleTime } from 'rxjs';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
import { Library } from 'src/app/_models/library';
import { Pagination } from 'src/app/_models/pagination';
import { PaginatedResult, Pagination } from 'src/app/_models/pagination';
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
const FILTER_PAG_REGEX = /[^0-9]/g;
const SCROLL_BREAKPOINT = 300;
const keySize = 24;
@Component({
selector: 'app-card-detail-layout',
templateUrl: './card-detail-layout.component.html',
styleUrls: ['./card-detail-layout.component.scss']
})
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit {
export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges {
@Input() header: string = '';
@Input() isLoading: boolean = false;
@Input() items: any[] = [];
// ?! we need to have chunks to render in, because if we scroll down, then up, then down, we don't want to trigger a duplicate call
@Input() paginatedItems: PaginatedResult<any> | undefined;
@Input() pagination!: Pagination;
// Filter Code
@ -35,56 +40,119 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
* Any actions to exist on the header for the parent collection (library, collection)
*/
@Input() actions: ActionItem<any>[] = [];
@Input() trackByIdentity!: (index: number, item: any) => string;
@Input() trackByIdentity!: TrackByFunction<any>; //(index: number, item: any) => string
@Input() filterSettings!: FilterSettings;
@Input() jumpBarKeys: Array<JumpKey> = []; // This is aprox 784 pixels wide
jumpBarKeysToRender: Array<JumpKey> = []; // Original
@Output() itemClicked: EventEmitter<any> = new EventEmitter();
@Output() pageChange: EventEmitter<Pagination> = new EventEmitter();
@Output() pageChangeWithDirection: EventEmitter<0 | 1> = new EventEmitter();
@Output() applyFilter: EventEmitter<FilterEvent> = new EventEmitter();
@ContentChild('cardItem') itemTemplate!: TemplateRef<any>;
@ContentChild('noData') noDataTemplate!: TemplateRef<any>;
@ViewChild('.jump-bar') jumpBar!: ElementRef<HTMLDivElement>;
@ViewChild('scroller') scroller!: CdkVirtualScrollViewport;
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
itemSize: number = 100; // Idk what this actually does. Less results in more items rendering, 5 works well with pagination. 230 is technically what a card is height wise
filter!: SeriesFilter;
libraries: Array<FilterItem<Library>> = [];
updateApplied: number = 0;
intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: 0.01 });
private onDestory: Subject<void> = new Subject();
get Breakpoint() {
return Breakpoint;
}
constructor(private seriesService: SeriesService, public utilityService: UtilityService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService) {
constructor(private seriesService: SeriesService, public utilityService: UtilityService,
@Inject(DOCUMENT) private document: Document, private ngZone: NgZone) {
this.filter = this.seriesService.createSeriesFilter();
}
@HostListener('window:resize', ['$event'])
@HostListener('window:orientationchange', ['$event'])
resizeJumpBar() {
console.log('resizing jump bar');
//const breakpoint = this.utilityService.getActiveBreakpoint();
// if (window.innerWidth < 784) {
// // We need to remove a few sections of keys
// const len = this.jumpBarKeys.length;
// if (this.jumpBarKeys.length <= 8) return;
// this.jumpBarKeys = this.jumpBarKeys.filter((item, index) => {
// return index % 2 === 0;
// });
// }
// TODO: Debounce this
const fullSize = (this.jumpBarKeys.length * keySize) - 20;
const currentSize = (this.document.querySelector('.jump-bar')?.getBoundingClientRect().height || fullSize + 20) - 20;
if (currentSize >= fullSize) {
return;
}
const targetNumberOfKeys = parseInt(Math.round(currentSize / keySize) + '', 10);
const removeCount = this.jumpBarKeys.length - targetNumberOfKeys - 3;
if (removeCount <= 0) return;
this.jumpBarKeysToRender = [];
const midPoint = this.jumpBarKeys.length / 2;
this.jumpBarKeysToRender.push(this.jumpBarKeys[0]);
this.removeFirstPartOfJumpBar(midPoint, removeCount / 2);
this.jumpBarKeysToRender.push(this.jumpBarKeys[midPoint]);
this.removeSecondPartOfJumpBar(midPoint, removeCount / 2);
this.jumpBarKeysToRender.push(this.jumpBarKeys[this.jumpBarKeys.length - 1]);
//console.log('End product: ', this.jumpBarKeysToRender);
// console.log('End key size: ', this.jumpBarKeysToRender.length);
}
removeSecondPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
const removedIndexes: Array<number> = [];
for(let removal = 0; removal < numberOfRemovals; removal++) {
let min = 100000000;
let minIndex = -1;
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
min = this.jumpBarKeys[i].size;
minIndex = i;
}
}
removedIndexes.push(minIndex);
}
// console.log('second: removing ', removedIndexes);
for(let i = midPoint + 1; i < this.jumpBarKeys.length - 2; i++) {
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
}
}
removeFirstPartOfJumpBar(midPoint: number, numberOfRemovals: number = 1) {
const removedIndexes: Array<number> = [];
for(let removal = 0; removal < numberOfRemovals; removal++) {
let min = 100000000;
let minIndex = -1;
for(let i = 1; i < midPoint; i++) {
if (this.jumpBarKeys[i].size < min && !removedIndexes.includes(i)) {
min = this.jumpBarKeys[i].size;
minIndex = i;
}
}
removedIndexes.push(minIndex);
}
// console.log('first: removing ', removedIndexes);
for(let i = 1; i < midPoint; i++) {
if (!removedIndexes.includes(i)) this.jumpBarKeysToRender.push(this.jumpBarKeys[i]);
}
}
ngOnInit(): void {
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.pagination?.currentPage}_${this.updateApplied}_${item?.libraryId}`;
if (this.trackByIdentity === undefined) {
this.trackByIdentity = (index: number, item: any) => `${this.header}_${this.updateApplied}_${item?.libraryId}`; // ${this.pagination?.currentPage}_
}
if (this.filterSettings === undefined) {
@ -96,27 +164,49 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
}
}
ngOnChanges(changes: SimpleChanges): void {
this.jumpBarKeysToRender = [...this.jumpBarKeys];
}
ngAfterViewInit() {
this.resizeJumpBar();
// this.scroller.elementScrolled().pipe(
// map(() => this.scroller.measureScrollOffset('bottom')),
// pairwise(),
// filter(([y1, y2]) => ((y2 < y1 && y2 < SCROLL_BREAKPOINT))), // 140
// throttleTime(200)
// ).subscribe(([y1, y2]) => {
// const movingForward = y2 < y1;
// if (this.pagination.currentPage === this.pagination.totalPages || this.pagination.currentPage === 1 && !movingForward) return;
// this.ngZone.run(() => {
// console.log('Load next pages');
const parent = this.document.querySelector('.card-container');
if (parent == null) return;
console.log('card divs', this.document.querySelectorAll('div[id^="jumpbar-index--"]'));
console.log('cards: ', this.document.querySelectorAll('.card'));
// this.pagination.currentPage = this.pagination.currentPage + 1;
// this.pageChangeWithDirection.emit(1);
// });
// });
Array.from(this.document.querySelectorAll('div')).forEach(elem => this.intersectionObserver.observe(elem));
// this.scroller.elementScrolled().pipe(
// map(() => this.scroller.measureScrollOffset('top')),
// pairwise(),
// filter(([y1, y2]) => y2 >= y1 && y2 < SCROLL_BREAKPOINT),
// throttleTime(200)
// ).subscribe(([y1, y2]) => {
// if (this.pagination.currentPage === 1) return;
// this.ngZone.run(() => {
// console.log('Load prev pages');
// this.pagination.currentPage = this.pagination.currentPage - 1;
// this.pageChangeWithDirection.emit(0);
// });
// });
}
ngOnDestroy() {
this.intersectionObserver.disconnect();
this.onDestory.next();
this.onDestory.complete();
}
handleIntersection(entries: IntersectionObserverEntry[]) {
console.log('interception: ', entries.filter(e => e.target.hasAttribute('no-observe')));
}
onPageChange(page: number) {
this.pageChange.emit(this.pagination);
@ -142,18 +232,21 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
this.updateApplied++;
}
// onScroll() {
loading: boolean = false;
fetchMore(event: IPageInfo) {
if (event.endIndex !== this.items.length - 1) return;
if (event.startIndex < 0) return;
console.log('Requesting next page ', (this.pagination.currentPage + 1), 'of data', event);
this.loading = true;
// }
// this.pagination.currentPage = this.pagination.currentPage + 1;
// this.pageChangeWithDirection.emit(1);
// onScrollDown() {
// console.log('scrolled down');
// }
// onScrollUp() {
// console.log('scrolled up');
// }
// this.fetchNextChunk(this.items.length, 10).then(chunk => {
// this.items = this.items.concat(chunk);
// this.loading = false;
// }, () => this.loading = false);
}
scrollTo(jumpKey: JumpKey) {
// TODO: Figure out how to do this
@ -165,6 +258,10 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
}
//console.log('scrolling to card that starts with ', jumpKey.key, + ' with index of ', targetIndex);
// Infinite scroll
this.virtualScroller.scrollToIndex(targetIndex, true, undefined, 1000);
return;
// Basic implementation based on itemsPerPage being the same.
//var minIndex = this.pagination.currentPage * this.pagination.itemsPerPage;
var targetPage = Math.max(Math.ceil(targetIndex / this.pagination.itemsPerPage), 1);
@ -173,14 +270,18 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, AfterViewIn
// Scroll to the element
const elem = this.document.querySelector(`div[id="jumpbar-index--${targetIndex}"`);
if (elem !== null) {
elem.scrollIntoView({
behavior: 'smooth'
});
this.virtualScroller.scrollToIndex(targetIndex);
// elem.scrollIntoView({
// behavior: 'smooth'
// });
}
return;
}
// With infinite scroll, we can't just jump to a random place, because then our list of items would be out of sync.
this.selectPageStr(targetPage + '');
//this.pageChangeWithDirection.emit(1);
// if (minIndex > targetIndex) {
// // We need to scroll forward (potentially to another page)

View file

@ -7,8 +7,8 @@
<app-image borderRadius=".25rem .25rem 0 0" height="230px" width="158px" [imageUrl]="imageService.errorImage"></app-image>
</ng-container>
<div class="progress-banner" *ngIf="read < total && total > 0 && read !== total">
<p><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
<div class="progress-banner">
<p *ngIf="read < total && total > 0 && read !== total"><ngb-progressbar type="primary" height="5px" [value]="read" [max]="total"></ngb-progressbar></p>
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>

View file

@ -19,6 +19,7 @@ import { Action, ActionItem } from 'src/app/_services/action-factory.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
import { ScrollService } from 'src/app/_services/scroll.service';
import { BulkSelectionService } from '../bulk-selection.service';
@Component({
@ -129,7 +130,7 @@ export class CardItemComponent implements OnInit, OnDestroy {
constructor(public imageService: ImageService, private libraryService: LibraryService,
public utilityService: UtilityService, private downloadService: DownloadService,
private toastr: ToastrService, public bulkSelectionService: BulkSelectionService,
private messageHub: MessageHubService, private accountService: AccountService) {}
private messageHub: MessageHubService, private accountService: AccountService, private scrollService: ScrollService) {}
ngOnInit(): void {
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
@ -182,28 +183,18 @@ export class CardItemComponent implements OnInit, OnDestroy {
@HostListener('touchstart', ['$event'])
onTouchStart(event: TouchEvent) {
if (!this.allowSelection) return;
const verticalOffset = (window.pageYOffset
|| document.documentElement.scrollTop
|| document.body.scrollTop || 0);
this.prevTouchTime = event.timeStamp;
this.prevOffset = verticalOffset;
this.prevOffset = this.scrollService.scrollPosition;
}
@HostListener('touchend', ['$event'])
onTouchEnd(event: TouchEvent) {
if (!this.allowSelection) return;
const delta = event.timeStamp - this.prevTouchTime;
const verticalOffset = (window.pageYOffset
|| document.documentElement.scrollTop
|| document.body.scrollTop || 0);
const verticalOffset = this.scrollService.scrollPosition;
if (verticalOffset != this.prevOffset) {
this.prevTouchTime = 0;
return;
}
if (delta >= 300 && delta <= 1000) {
if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset)) {
this.handleSelection();
event.stopPropagation();
event.preventDefault();

View file

@ -23,6 +23,12 @@ import { FileInfoComponent } from './file-info/file-info.component';
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component';
import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-drawer.component';
import { EntityTitleComponent } from './entity-title/entity-title.component';
import { EntityInfoCardsComponent } from './entity-info-cards/entity-info-cards.component';
import { ListItemComponent } from './list-item/list-item.component';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component';
@ -43,6 +49,10 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
FileInfoComponent,
EditSeriesRelationComponent,
CardDetailDrawerComponent,
EntityTitleComponent,
EntityInfoCardsComponent,
ListItemComponent,
SeriesInfoCardsComponent,
],
imports: [
CommonModule,
@ -60,8 +70,7 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
NgbCollapseModule,
NgbRatingModule,
//ScrollingModule,
//InfiniteScrollModule,
VirtualScrollerModule,
NgbOffcanvasModule, // Series Detail, action of cards
@ -93,7 +102,16 @@ import { CardDetailDrawerComponent } from './card-detail-drawer/card-detail-draw
ChapterMetadataDetailComponent,
EditSeriesRelationComponent,
NgbOffcanvasModule
EntityTitleComponent,
EntityInfoCardsComponent,
ListItemComponent,
NgbOffcanvasModule,
VirtualScrollerModule,
SeriesInfoCardsComponent
]
})
export class CardsModule { }

View file

@ -48,11 +48,27 @@
</form>
<div class="row g-0 chooser" style="padding-top: 10px">
<div class="image-card col-auto {{selectedIndex === idx ? 'selected' : ''}}" *ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)">
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url"></app-image>
<div class="image-card col-auto"
*ngFor="let url of imageUrls; let idx = index;" tabindex="0" attr.aria-label="Image {{idx + 1}}" (click)="selectImage(idx)"
[ngClass]="{'selected': !showApplyButton && selectedIndex === idx}">
<app-image class="card-img-top" height="230px" width="158px" [imageUrl]="url" [processEvents]="idx > 0"></app-image>
<ng-container *ngIf="showApplyButton">
<br>
<button class="btn btn-primary" style="width: 100%;" aria-label="Apply for uploaded image"
(click)="applyImage(idx)">
{{appliedIndex === idx ? 'Applied' : 'Apply'}}
</button>
</ng-container>
</div>
<div class="image-card col-auto {{selectedIndex === -1 ? 'selected' : ''}}" *ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()">
<div class="image-card col-auto"
*ngIf="showReset" tabindex="0" attr.aria-label="Reset cover image" (click)="reset()"
[ngClass]="{'selected': !showApplyButton && selectedIndex === -1}">
<app-image class="card-img-top" title="Reset Cover Image" height="230px" width="158px" [imageUrl]="imageService.resetCoverImage"></app-image>
<ng-container *ngIf="showApplyButton">
<br>
<button style="width: 100%;" class="btn btn-secondary" aria-label="Reset to generated image" (click)="resetImage()">Reset</button>
</ng-container>
</div>
</div>

View file

@ -9,6 +9,8 @@ import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { UploadService } from 'src/app/_services/upload.service';
import { DOCUMENT } from '@angular/common';
export type SelectCoverFunction = (selectedCover: string) => void;
@Component({
selector: 'app-cover-image-chooser',
templateUrl: './cover-image-chooser.component.html',
@ -16,6 +18,19 @@ import { DOCUMENT } from '@angular/common';
})
export class CoverImageChooserComponent implements OnInit, OnDestroy {
/**
* If buttons show under images to allow immediate selection of cover images.
*/
@Input() showApplyButton: boolean = false;
/**
* When a cover image is selected, this will be called with a base url representation of the file.
*/
@Output() applyCover: EventEmitter<string> = new EventEmitter<string>();
/**
* When a cover image is reset, this will be called.
*/
@Output() resetCover: EventEmitter<void> = new EventEmitter<void>();
@Input() imageUrls: Array<string> = [];
@Output() imageUrlsChange: EventEmitter<Array<string>> = new EventEmitter<Array<string>>();
@ -37,6 +52,10 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
selectedIndex: number = 0;
/**
* Only applies for showApplyButton. Used to track which image is applied.
*/
appliedIndex: number = 0;
form!: FormGroup;
files: NgxFileDropEntry[] = [];
@ -78,6 +97,19 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
}
applyImage(index: number) {
if (this.showApplyButton) {
this.applyCover.emit(this.imageUrls[index]);
this.appliedIndex = index;
}
}
resetImage() {
if (this.showApplyButton) {
this.resetCover.emit();
}
}
loadImage() {
const url = this.form.get('coverImageUrl')?.value.trim();
if (url && url != '') {

View file

@ -0,0 +1,66 @@
<div class="row g-0 mt-4 mb-3">
<ng-container *ngIf="totalPages > 0">
<div class="col-auto mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines" title="Pages">
{{totalPages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter !== undefined && chapter.releaseDate && (chapter.releaseDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title label="Release Date" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release">
{{chapter.releaseDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0 || chapter.files[0].format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
<ng-container *ngIf="readingTime.maxHours === 0; else normalReadTime">&lt;1 Hour</ng-container>
<ng-template #normalReadTime>
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</ng-template>
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.files[0].format === MangaFormat.EPUB && totalWordCount > 0">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{totalWordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="chapter.ageRating !== AgeRating.Unknown">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Age Rating" [clickable]="false" fontClasses="fas fa-eye" title="Age Rating">
{{chapter.ageRating | ageRating | async}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties && chapter.created && chapter.created !== '' && (chapter.created | date: 'shortDate') !== '1/1/01'">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="Date Added" [clickable]="false" fontClasses="fa-solid fa-file-import" title="Date Added">
{{chapter.created | date:'short' || '-'}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="showExtendedProperties">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-auto">
<app-icon-and-title label="ID" [clickable]="false" fontClasses="fa-solid fa-fingerprint" title="ID">
{{entity.id}}
</app-icon-and-title>
</div>
</ng-container>
</div>

View file

@ -0,0 +1,96 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { ChapterMetadata } from 'src/app/_models/chapter-metadata';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { LibraryType } from 'src/app/_models/library';
import { MangaFormat } from 'src/app/_models/manga-format';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { Volume } from 'src/app/_models/volume';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-entity-info-cards',
templateUrl: './entity-info-cards.component.html',
styleUrls: ['./entity-info-cards.component.scss']
})
export class EntityInfoCardsComponent implements OnInit, OnDestroy {
@Input() entity!: Volume | Chapter;
/**
* This will pull extra information
*/
@Input() includeMetadata: boolean = false;
/**
* Hide more system based fields, like Id or Date Added
*/
@Input() showExtendedProperties: boolean = true;
isChapter = false;
chapter!: Chapter;
chapterMetadata!: ChapterMetadata;
ageRating!: string;
totalPages: number = 0;
totalWordCount: number = 0;
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
private readonly onDestroy: Subject<void> = new Subject();
get LibraryType() {
return LibraryType;
}
get MangaFormat() {
return MangaFormat;
}
get AgeRating() {
return AgeRating;
}
constructor(private utilityService: UtilityService, private seriesService: SeriesService) {}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
this.chapter = this.utilityService.isChapter(this.entity) ? (this.entity as Chapter) : (this.entity as Volume).chapters[0];
if (this.includeMetadata) {
this.seriesService.getChapterMetadata(this.chapter.id).subscribe(metadata => {
this.chapterMetadata = metadata;
});
}
this.totalPages = this.chapter.pages;
if (!this.isChapter) {
this.totalPages = this.utilityService.asVolume(this.entity).pages;
}
this.totalWordCount = this.chapter.wordCount;
if (!this.isChapter) {
this.totalWordCount = this.utilityService.asVolume(this.entity).chapters.map(c => c.wordCount).reduce((sum, d) => sum + d);
}
if (this.isChapter) {
this.readingTime.minHours = this.chapter.minHoursToRead;
this.readingTime.maxHours = this.chapter.maxHoursToRead;
this.readingTime.avgHours = this.chapter.avgHoursToRead;
} else {
const vol = this.utilityService.asVolume(this.entity);
this.readingTime.minHours = vol.minHoursToRead;
this.readingTime.maxHours = vol.maxHoursToRead;
this.readingTime.avgHours = vol.avgHoursToRead;
}
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
}

View file

@ -0,0 +1,29 @@
<ng-container [ngSwitch]="libraryType">
<ng-container *ngSwitchCase="LibraryType.Comic">
<ng-container *ngIf="titleName != '' && prioritizeTitleName; else fullComicTitle">
{{titleName}}
</ng-container>
<ng-template #fullComicTitle>
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
<ng-container *ngIf="includeVolume && volumeTitle != ''">
{{entity.number != 0 ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
</ng-container>
{{entity.number != 0 ? (isChapter ? 'Issue #' + entity.number : volumeTitle) : 'Special'}}
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Manga">
<ng-container *ngIf="titleName != '' && prioritizeTitleName; else fullMangaTitle">
{{titleName}}
</ng-container>
<ng-template #fullMangaTitle>
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
<ng-container *ngIf="includeVolume && volumeTitle != ''">
{{entity.number != 0 ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
</ng-container>
{{entity.number != 0 ? (isChapter ? 'Chapter ' + entity.number : volumeTitle) : 'Special'}}
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Book">
{{volumeTitle}}
</ng-container>
</ng-container>

View file

@ -0,0 +1,57 @@
import { Component, Input, OnInit } from '@angular/core';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library';
import { Volume } from 'src/app/_models/volume';
@Component({
selector: 'app-entity-title',
templateUrl: './entity-title.component.html',
styleUrls: ['./entity-title.component.scss']
})
export class EntityTitleComponent implements OnInit {
/**
* Library type for which the entity belongs
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
@Input() seriesName: string = '';
@Input() entity!: Volume | Chapter;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@Input() includeVolume: boolean = false;
/**
* When a titleName (aka a title) is avaliable on the entity, show it over Volume X Chapter Y
*/
@Input() prioritizeTitleName: boolean = true;
isChapter = false;
chapter!: Chapter;
titleName: string = '';
volumeTitle: string = '';
get LibraryType() {
return LibraryType;
}
constructor(private utilityService: UtilityService) {
}
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
if (this.isChapter) {
const c = (this.entity as Chapter);
this.volumeTitle = c.volumeTitle || '';
this.titleName = c.titleName || '';
} else {
const v = this.utilityService.asVolume(this.entity);
this.volumeTitle = v.name || '';
this.titleName = v.chapters[0].titleName || '';
}
}
}

View file

@ -0,0 +1,40 @@
<div class="list-item-container d-flex flex-row g-0 mb-2 p-2">
<div class="pe-2">
<app-image [imageUrl]="imageUrl" [height]="imageHeight" [width]="imageWidth"></app-image>
<span class="download" *ngIf="download$ | async as download">
<app-circular-loader [currentValue]="download.progress"></app-circular-loader>
<span class="visually-hidden" role="status">
{{download.progress}}% downloaded
</span>
</span>
<div class="progress-banner" *ngIf="pagesRead < totalPages && totalPages > 0 && pagesRead !== totalPages">
<p><ngb-progressbar type="primary" height="5px" [value]="pagesRead" [max]="totalPages"></ngb-progressbar></p>
</div>
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5 style="margin-bottom: 0px">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="seriesName" iconClass="fa-ellipsis-v"></app-card-actionables>
<ng-content select="[title]"></ng-content>
<button class="btn btn-primary float-end" (click)="read.emit()">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-sm-inline-block">Read</span>
</button>
</h5>
<!-- This isn't perfect, but it might work. TODO: Polish this-->
<h6 class="text-muted" [ngClass]="{'subtitle-with-actionables' : actions.length > 0}" style="font-size: 0.75rem" *ngIf="Title != '' && showTitle">{{Title}}</h6>
<ng-container *ngIf="summary.length > 0">
<div class="mt-2 ps-2">
<app-read-more [text]="summary" [maxLength]="250"></app-read-more>
</div>
</ng-container>
<div class="ps-2 d-none d-md-inline-block">
<app-entity-info-cards [entity]="entity" [showExtendedProperties]="false"></app-entity-info-cards>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
$image-height: 230px;
$image-width: 160px;
.download {
width: 80px;
height: 80px;
position: absolute;
top: 55px;
left: 20px;
}
.progress-banner {
height: 5px;
.progress {
color: var(--card-progress-bar-color);
background-color: transparent;
}
}
.list-item-container {
background: rgb(0,0,0);
background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.15) 1%, rgba(0,0,0,0) 100%);
border-radius: 5px;
position: relative;
}

View file

@ -0,0 +1,140 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { finalize, Observable, of, take, takeWhile } from 'rxjs';
import { Download } from 'src/app/shared/_models/download';
import { DownloadService } from 'src/app/shared/_services/download.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { Chapter } from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library';
import { Series } from 'src/app/_models/series';
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
import { Volume } from 'src/app/_models/volume';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
@Component({
selector: 'app-list-item',
templateUrl: './list-item.component.html',
styleUrls: ['./list-item.component.scss']
})
export class ListItemComponent implements OnInit {
/**
* Volume or Chapter to render
*/
@Input() entity!: Volume | Chapter;
/**
* Image to show
*/
@Input() imageUrl: string = '';
/**
* Actions to show
*/
@Input() actions: ActionItem<any>[] = []; // Volume | Chapter
/**
* Library type to help with formatting title
*/
@Input() libraryType: LibraryType = LibraryType.Manga;
/**
* Name of the Series to show under the title
*/
@Input() seriesName: string = '';
/**
* Size of the Image Height. Defaults to 230px.
*/
@Input() imageHeight: string = '230px';
/**
* Size of the Image Width Defaults to 158px.
*/
@Input() imageWidth: string = '158px';
@Input() seriesLink: string = '';
@Input() pagesRead: number = 0;
@Input() totalPages: number = 0;
@Input() relation: RelationKind | undefined = undefined;
/**
* When generating the title, should this prepend 'Volume number' before the Chapter wording
*/
@Input() includeVolume: boolean = false;
/**
* Show's the title if avaible on entity
*/
@Input() showTitle: boolean = true;
@Output() read: EventEmitter<void> = new EventEmitter<void>();
actionInProgress: boolean = false;
summary$: Observable<string> = of('');
summary: string = '';
isChapter: boolean = false;
download$: Observable<Download> | null = null;
downloadInProgress: boolean = false;
get Title() {
if (this.isChapter) return (this.entity as Chapter).titleName;
return '';
}
constructor(private utilityService: UtilityService, private downloadService: DownloadService, private toastr: ToastrService) { }
ngOnInit(): void {
this.isChapter = this.utilityService.isChapter(this.entity);
if (this.isChapter) {
this.summary = this.utilityService.asChapter(this.entity).summary || '';
} else {
this.summary = this.utilityService.asVolume(this.entity).chapters[0].summary || '';
}
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
if (this.downloadInProgress === true) {
this.toastr.info('Download is already in progress. Please wait.');
return;
}
if (this.utilityService.isVolume(this.entity)) {
const volume = this.utilityService.asVolume(this.entity);
this.downloadService.downloadVolumeSize(volume.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'volume');
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.download$ = this.downloadService.downloadVolume(volume).pipe(
takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
}));
});
} else if (this.utilityService.isChapter(this.entity)) {
const chapter = this.utilityService.asChapter(this.entity);
this.downloadService.downloadChapterSize(chapter.id).pipe(take(1)).subscribe(async (size) => {
const wantToDownload = await this.downloadService.confirmSize(size, 'chapter');
if (!wantToDownload) { return; }
this.downloadInProgress = true;
this.download$ = this.downloadService.downloadChapter(chapter).pipe(
takeWhile(val => {
return val.state != 'DONE';
}),
finalize(() => {
this.download$ = null;
this.downloadInProgress = false;
}));
});
}
return; // Don't propagate the download from a card
}
if (typeof action.callback === 'function') {
action.callback(action.action, this.entity);
}
}
}

View file

@ -72,7 +72,8 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
ngOnChanges(changes: any) {
if (this.data) {
this.actions = this.actionFactoryService.getSeriesActions((action: Action, series: Series) => this.handleSeriesActionCallback(action, series));
this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
//this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id));
this.imageUrl = this.imageService.getSeriesCoverImage(this.data.id); // TODO: Do I need to do this since image now handles updates?
}
}

View file

@ -0,0 +1,103 @@
<div class="row g-0 mb-4 mt-3">
<ng-container *ngIf="seriesMetadata">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Age Rating" [clickable]="true" fontClasses="fas fa-eye" (click)="handleGoTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" title="Age Rating">
{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Release Year" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Language" [clickable]="true" fontClasses="fas fa-language" (click)="handleGoTo(FilterQueryParam.Languages, seriesMetadata.language)" title="Language">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title label="Publication" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === 'Ongoing' ? 'empty' : 'end'}}" (click)="handleGoTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})">
{{pubStatus}}
</app-icon-and-title>
</ng-container>
</div>
<div class="vr m-2 d-none d-lg-block"></div>
</ng-container>
<ng-container *ngIf="series">
<ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Format" [clickable]="true" [fontClasses]="'fa ' + utilityService.mangaFormatIcon(series.format)" (click)="handleGoTo(FilterQueryParam.Format, series.format)" title="Format">
{{utilityService.mangaFormat(series.format)}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Last Read" [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
{{series.latestReadDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-template #showPages>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{series.pages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-template>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="hasReadingProgress && showReadingTimeLeft && readingTimeLeft && readingTimeLeft.avgHours !== 0 ">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Left" [clickable]="false" fontClasses="fa-solid fa-clock">
~{{readingTimeLeft.avgHours}} Hour{{readingTimeLeft.avgHours > 1 ? 's' : ''}} Left
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>

View file

@ -0,0 +1,55 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FilterQueryParam } from 'src/app/shared/_services/filter-utilities.service';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { MangaFormat } from 'src/app/_models/manga-format';
import { Series } from 'src/app/_models/series';
import { SeriesMetadata } from 'src/app/_models/series-metadata';
import { MetadataService } from 'src/app/_services/metadata.service';
import { ReaderService } from 'src/app/_services/reader.service';
@Component({
selector: 'app-series-info-cards',
templateUrl: './series-info-cards.component.html',
styleUrls: ['./series-info-cards.component.scss']
})
export class SeriesInfoCardsComponent implements OnInit {
@Input() series!: Series;
@Input() seriesMetadata!: SeriesMetadata;
@Input() hasReadingProgress: boolean = false;
@Input() readingTimeLeft: HourEstimateRange | undefined;
/**
* If this should make an API call to request readingTimeLeft
*/
@Input() showReadingTimeLeft: boolean = true;
@Output() goTo: EventEmitter<{queryParamName: FilterQueryParam, filter: any}> = new EventEmitter();
readingTime: HourEstimateRange = {avgHours: 0, maxHours: 0, minHours: 0};
get MangaFormat() {
return MangaFormat;
}
get FilterQueryParam() {
return FilterQueryParam;
}
constructor(public utilityService: UtilityService, public metadataService: MetadataService, private readerService: ReaderService) { }
ngOnInit(): void {
if (this.series !== null) {
if (this.showReadingTimeLeft) this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft);
this.readingTime.minHours = this.series.minHoursToRead;
this.readingTime.maxHours = this.series.maxHoursToRead;
this.readingTime.avgHours = this.series.avgHoursToRead;
}
}
handleGoTo(queryParamName: FilterQueryParam, filter: any) {
this.goTo.emit({queryParamName, filter});
}
}

View file

@ -23,13 +23,17 @@
[items]="series"
[pagination]="pagination"
[filterSettings]="filterSettings"
[trackByIdentity]="trackByIdentity"
[filterOpen]="filterOpen"
[jumpBarKeys]="jumpKeys"
(applyFilter)="updateFilter($event)"
(pageChange)="onPageChange($event)"
(pageChangeWithDirection)="handlePaginationChange($event)"
>
<ng-template #cardItem let-item let-position="idx">
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (reload)="loadPage()" (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"></app-series-card>
<app-series-card [data]="item" [libraryId]="libraryId" [suppressLibraryLink]="true" (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>
</ng-container>

View file

@ -105,12 +105,13 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
});
this.libraryService.getJumpBar(this.libraryId).subscribe(barDetails => {
console.log('JumpBar: ', barDetails);
//console.log('JumpBar: ', barDetails);
this.jumpKeys = barDetails;
});
this.actions = this.actionFactoryService.getLibraryActions(this.handleAction.bind(this));
this.pagination = this.filterUtilityService.pagination(this.route.snapshot);
this.pagination.itemsPerPage = 0; // TODO: Validate what pagination setting is ideal
[this.filterSettings.presets, this.filterSettings.openByDefault] = this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot);
if (this.filterSettings.presets) this.filterSettings.presets.libraries = [this.libraryId];
// Setup filterActiveCheck to check filter against
@ -178,7 +179,12 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
this.loadPage();
}
loadPage() {
handlePaginationChange(direction: 0 | 1) {
this.filterUtilityService.updateUrlFromFilter(this.pagination, undefined);
this.loadPage(direction);
}
loadPage(direction: 0 | 1 = 1) {
// The filter is out of sync with the presets from typeaheads on first load but syncs afterwards
if (this.filter == undefined) {
this.filter = this.seriesService.createSeriesFilter();
@ -188,7 +194,18 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
this.loadingSeries = true;
this.filterActive = !this.utilityService.deepEqual(this.filter, this.filterActiveCheck);
this.seriesService.getSeriesForLibrary(0, this.pagination?.currentPage, this.pagination?.itemsPerPage, this.filter).pipe(take(1)).subscribe(series => {
this.series = series.result;
//this.series = series.result; // Non-infinite scroll version
if (this.series.length === 0) {
this.series = series.result;
} else {
if (direction === 1) {
//this.series = [...this.series, ...series.result];
this.series.concat(series.result);
} else {
this.series = [...series.result, ...this.series];
}
}
this.pagination = series.pagination;
this.loadingSeries = false;
window.scrollTo(0, 0);
@ -204,5 +221,5 @@ export class LibraryDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['library', this.libraryId, 'series', series.id]);
}
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.originalName}_${item.localizedName}_${item.pagesRead}`;
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
}

View file

@ -0,0 +1,3 @@
{
"lockfileVersion": 1
}

View file

@ -0,0 +1,22 @@
import { Pipe, PipeTransform } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AgeRating } from '../_models/metadata/age-rating';
import { AgeRatingDto } from '../_models/metadata/age-rating-dto';
import { MetadataService } from '../_services/metadata.service';
@Pipe({
name: 'ageRating'
})
export class AgeRatingPipe implements PipeTransform {
constructor(private metadataService: MetadataService) {}
transform(value: AgeRating | AgeRatingDto): Observable<string> {
if (value.hasOwnProperty('title')) {
return of((value as AgeRatingDto).title);
}
return this.metadataService.getAgeRating((value as AgeRating));
}
}

View file

@ -9,6 +9,7 @@ import { RelationshipPipe } from './relationship.pipe';
import { DefaultValuePipe } from './default-value.pipe';
import { CompactNumberPipe } from './compact-number.pipe';
import { LanguageNamePipe } from './language-name.pipe';
import { AgeRatingPipe } from './age-rating.pipe';
@ -22,7 +23,8 @@ import { LanguageNamePipe } from './language-name.pipe';
RelationshipPipe,
DefaultValuePipe,
CompactNumberPipe,
LanguageNamePipe
LanguageNamePipe,
AgeRatingPipe
],
imports: [
CommonModule,
@ -36,7 +38,8 @@ import { LanguageNamePipe } from './language-name.pipe';
RelationshipPipe,
DefaultValuePipe,
CompactNumberPipe,
LanguageNamePipe
LanguageNamePipe,
AgeRatingPipe
]
})
export class PipeModule { }

View file

@ -45,14 +45,14 @@
</div>
</div>
<div *ngIf="items.length === 0">
<div class="mx-auto" *ngIf="items.length === 0" style="width: 200px;">
Nothing added
</div>
</div>
<app-dragable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)" [accessibilityMode]="accessibilityMode">
<ng-template #draggableItem let-item let-position="idx">
<div class="d-flex" style="width: 100%;">
<div class="d-flex" style="width: 100%;">
<app-image width="74px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1" id="item.id--{{position}}">{{formatTitle(item)}}&nbsp;

View file

@ -1,4 +1,4 @@
<app-side-nav-companion-bar *ngIf="series !== undefined">
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
<ng-container title>
<h2 style="margin-bottom: 0px">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
@ -8,9 +8,52 @@
<ng-container subtitle *ngIf="series?.localizedName !== series?.name">
<h6 class="subtitle-with-actionables" title="Localized Name">{{series?.localizedName}}</h6>
</ng-container>
<ng-template #extrasDrawer let-offcanvas>
<div class="offcanvas-header">
<h4 class="offcanvas-title" id="offcanvas-basic-title">Page Settings</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="offcanvas.dismiss()"></button>
</div>
<div class="offcanvas-body">
<form [formGroup]="pageExtrasGroup">
<!-- <div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<label for="settings-book-reading-direction" class="form-label">Sort Order</label>
<button class="btn btn-sm btn-secondary-outline" (click)="updateSortOrder()" style="height: 25px; padding-bottom: 0px;">
<i class="fa fa-arrow-up" title="Ascending" *ngIf="isAscendingSort; else descSort"></i>
<ng-template #descSort>
<i class="fa fa-arrow-down" title="Descending"></i>
</ng-template>
</button>
<select class="form-select" aria-describedby="settings-reading-direction-help" formControlName="sortingOption">
<option *ngFor="let opt of sortingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div> -->
<div class="row g-0">
<div class="col-md-12 col-sm-12 pe-2 mb-3">
<label id="list-layout-mode-label" class="form-label">Layout Mode</label>
<br/>
<div class="btn-group d-flex justify-content-center" role="group" aria-label="Layout Mode">
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.Cards" class="btn-check" id="layout-mode-default" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-default">Card</label>
<input type="radio" formControlName="renderMode" [value]="PageLayoutMode.List" class="btn-check" id="layout-mode-col1" autocomplete="off">
<label class="btn btn-outline-primary" for="layout-mode-col1">List</label>
</div>
</div>
</div>
</form>
</div>
</ng-template>
</app-side-nav-companion-bar>
<div class="container-fluid pt-2" *ngIf="series !== undefined">
<div class="row mb-3">
<div (scroll)="onScroll()" class="main-container container-fluid pt-2" *ngIf="series !== undefined" #scrollingBlock>
<div class="row mb-3 info-container">
<div class="col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
<app-image maxWidth="300px" [imageUrl]="seriesImage"></app-image>
<!-- NOTE: We can put continue point here as Vol X Ch Y or just Ch Y or Book Z ?-->
@ -37,7 +80,7 @@
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
</div>
</div>
<div class="col-auto ms-2" *ngIf="isAdmin || hasDownloadingRole">
<button class="btn btn-secondary" (click)="downloadSeries()" title="Download Series" [disabled]="downloadInProgress">
<ng-container *ngIf="downloadInProgress; else notDownloading">
@ -59,82 +102,173 @@
<app-read-more class="user-review {{userReview ? 'mt-1' : ''}}" [text]="series?.userReview || ''" [maxLength]="250"></app-read-more>
</div>
<div *ngIf="seriesMetadata" class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"></app-series-metadata-detail>
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series" [hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
</div>
</div>
</div>
<ng-container>
<ng-container *ngIf="series">
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs mb-2" [destroyOnHide]="false" (navChange)="onNavChange($event)">
<li [ngbNavItem]="TabID.Storyline" *ngIf="libraryType !== LibraryType.Book && (volumes.length > 0 || chapters.length > 0)">
<a ngbNavLink>Storyline</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let volume of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="volume.number != 0" [entity]="volume" [title]="volume.name" (click)="openVolume(volume)"
[imageUrl]="imageService.getVolumeCoverImage(volume.id)"
[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>
<virtual-scroller #scroll [items]="storylineItems" [bufferAmount]="1" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else storylineListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
<ng-container *ngIf="!item.isChapter; else chapterCardItem">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="item.volume.number != 0" [entity]="item.volume" [title]="item.volume.name" (click)="openVolume(item.volume)"
[imageUrl]="imageService.getVolumeCoverImage(item.volume.id)"
[read]="item.volume.pagesRead" [total]="item.volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true"></app-card-item>
</ng-container>
<ng-template #chapterCardItem>
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.chapter.isSpecial" [entity]="item.chapter" [title]="item.chapter.title" (click)="openChapter(item.chapter)"
[imageUrl]="imageService.getChapterCoverImage(item.chapter.id)"
[read]="item.chapter.pagesRead" [total]="item.chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, storyChapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
</ng-template>
</ng-container>
</div>
</ng-container>
<ng-container *ngFor="let chapter of storyChapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!chapter.isSpecial" [entity]="chapter" [title]="chapter.title" (click)="openChapter(chapter)"
[imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[read]="chapter.pagesRead" [total]="chapter.pages" [actions]="chapterActions" (selection)="bulkSelectionService.handleCardSelection('chapter', idx, storyChapters.length, $event)" [selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
</ng-container>
</div>
<ng-template #storylineListLayout>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByStoryLineIdentity">
<ng-container *ngIf="!item.isChapter; else chapterListItem">
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(item.volume.id)"
[seriesName]="series.name" [entity]="item.volume" *ngIf="item.volume.number != 0"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.volume.pagesRead" [totalPages]="item.volume.pages" (read)="openVolume(item.volume)">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item.volume" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
</ng-container>
<ng-template #chapterListItem>
<app-list-item [imageUrl]="imageService.getChapterCoverImage(item.chapter.id)"
[seriesName]="series.name" [entity]="item.chapter" *ngIf="!item.chapter.isSpecial"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="item.chapter.pagesRead" [totalPages]="item.chapter.pages" (read)="openChapter(item.chapter)">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item.chapter" [seriesName]="series.name" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
</ng-template>
</ng-container>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Volumes" *ngIf="volumes.length > 0">
<a ngbNavLink>{{libraryType === LibraryType.Book ? 'Books': 'Volumes'}}</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of volumes; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
(selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)"
[selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true">
</app-card-item>
<virtual-scroller #scroll [items]="volumes" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else volumeListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
<app-card-item class="col-auto mt-2 mb-2" [entity]="item" [title]="item.name" (click)="openVolume(item)"
[imageUrl]="imageService.getVolumeCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="volumeActions"
(selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)"
[selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true">
</app-card-item>
</ng-container>
</div>
</ng-container>
</div>
<ng-template #volumeListLayout>
<ng-container *ngFor="let volume of scroll.viewPortItems; let idx = index; trackBy: trackByVolumeIdentity">
<app-list-item [imageUrl]="imageService.getVolumeCoverImage(volume.id)"
[seriesName]="series.name" [entity]="volume" *ngIf="volume.number != 0"
[actions]="volumeActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="volume.pagesRead" [totalPages]="volume.pages" (read)="openVolume(volume)">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="volume" [seriesName]="series.name"></app-entity-title>
</ng-container>
</app-list-item>
</ng-container>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
</li>
<li [ngbNavItem]="TabID.Chapters" *ngIf="chapters.length > 0">
<a ngbNavLink>{{utilityService.formatChapterName(libraryType) + 's'}}</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of chapters; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true"></app-card-item>
<virtual-scroller #scroll [items]="chapters" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else chapterListLayout">
<div class="card-container row g-0" #container>
<div *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-2" *ngIf="!item.isSpecial" [entity]="item" [title]="item.title" (click)="openChapter(item)"
[imageUrl]="imageService.getChapterCoverImage(item.id)"
[read]="item.pagesRead" [total]="item.pages" [actions]="chapterActions"
(selection)="bulkSelectionService.handleCardSelection('chapter', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('chapter', idx)" [allowSelection]="true">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="item" [seriesName]="series.name" [includeVolume]="true"></app-entity-title>
</ng-container>
</app-card-item>
</div>
</div>
</ng-container>
</div>
<ng-template #chapterListLayout>
<div *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-list-item [imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[seriesName]="series.name" [entity]="chapter" *ngIf="!chapter.isSpecial"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="chapter.pagesRead" [totalPages]="chapter.pages" (read)="openChapter(chapter)"
[includeVolume]="true">
<ng-container title>
<app-entity-title [libraryType]="libraryType" [entity]="chapter" [seriesName]="series.name" [includeVolume]="true" [prioritizeTitleName]="false"></app-entity-title>
</ng-container>
</app-list-item>
</div>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
<a ngbNavLink>Specials</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of specials; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-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', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true"></app-card-item>
<virtual-scroller #scroll [items]="specials" [parentScroll]="scrollingBlock">
<ng-container *ngIf="renderMode === PageLayoutMode.Cards; else specialListLayout">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-card-item class="col-auto mt-2 mb-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', idx, chapters.length, $event)"
[selected]="bulkSelectionService.isCardSelected('special', idx)" [allowSelection]="true">
</app-card-item>
</ng-container>
</div>
</ng-container>
</div>
<ng-template #specialListLayout>
<ng-container *ngFor="let chapter of scroll.viewPortItems; let idx = index; trackBy: trackByChapterIdentity">
<app-list-item [imageUrl]="imageService.getChapterCoverImage(chapter.id)"
[seriesName]="series.name" [entity]="chapter"
[actions]="chapterActions" [libraryType]="libraryType" imageWidth="130px" imageHeight=""
[pagesRead]="chapter.pagesRead" [totalPages]="chapter.pages" (read)="openChapter(chapter)">
<ng-container title>
{{chapter.title || chapter.range}}
</ng-container>
</app-list-item>
</ng-container>
</ng-template>
</virtual-scroller>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Related" *ngIf="hasRelations">
<a ngbNavLink>Related</a>
<ng-template ngbNavContent>
<div class="card-container row g-0">
<ng-container *ngFor="let item of relations; let idx = index; trackBy: trackByRelatedSeriesIdentiy">
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
</ng-container>
</div>
<virtual-scroller #scroll [items]="relations" [parentScroll]="scrollingBlock">
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems let idx = index; trackBy: trackByRelatedSeriesIdentiy">
<app-series-card class="col-auto mt-2 mb-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
</ng-container>
</div>
</virtual-scroller>
</ng-template>
</li>
</ul>
@ -147,3 +281,4 @@
</div>
</div>
</div>

View file

@ -13,3 +13,24 @@
grid-gap: 0.5rem;
justify-content: space-around;
}
.virtual-scroller, virtual-scroller {
width: 100%;
//height: 10000px;
height: calc(100vh - 85px);
max-height: calc(var(--vh)*100 - 170px);
}
// This is responsible for ensuring we scroll down and only tabs and companion bar is visible
.main-container {
height: calc(var(--vh)*100 - 117px);
overflow-y: auto;
}
.nav-tabs.fixed {
position: fixed;
top: 114px;
z-index: 100;
background-color: var(--bs-body-bg);
width: 100%;
}

View file

@ -1,12 +1,11 @@
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { Component, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbModal, NgbNavChangeEvent, NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { forkJoin, Subject } from 'rxjs';
import { finalize, mergeMap, take, takeUntil, takeWhile } from 'rxjs/operators';
import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators';
import { BulkSelectionService } from '../cards/bulk-selection.service';
import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component';
import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component';
import { ConfirmConfig } from '../shared/confirm-dialog/_models/confirm-config';
import { ConfirmService } from '../shared/confirm.service';
@ -36,6 +35,9 @@ import { NavService } from '../_services/nav.service';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { RelationKind } from '../_models/series-detail/relation-kind';
import { CardDetailDrawerComponent } from '../cards/card-detail-drawer/card-detail-drawer.component';
import { FormControl, FormGroup } from '@angular/forms';
import { PageLayoutMode } from '../_models/page-layout-mode';
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
interface RelatedSeris {
series: Series;
@ -50,6 +52,12 @@ enum TabID {
Chapters = 4
}
interface StoryLineItem {
chapter?: Chapter;
volume?: Volume;
isChapter: boolean;
}
@Component({
selector: 'app-series-detail',
templateUrl: './series-detail.component.html',
@ -57,6 +65,8 @@ enum TabID {
})
export class SeriesDetailComponent implements OnInit, OnDestroy {
@ViewChild(VirtualScrollerComponent) private virtualScroller!: VirtualScrollerComponent;
/**
* Series Id. Set at load before UI renders
*/
@ -65,6 +75,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
volumes: Volume[] = [];
chapters: Chapter[] = [];
storyChapters: Chapter[] = [];
storylineItems: StoryLineItem[] = [];
libraryId = 0;
isAdmin = false;
hasDownloadingRole = false;
@ -101,6 +112,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
actionInProgress: boolean = false;
itemSize: number = 10; // when 10 done, 16 loads
/**
* Track by function for Volume to tell when to refresh card data
*/
@ -110,6 +123,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
trackByChapterIdentity = (index: number, item: Chapter) => `${item.title}_${item.number}_${item.pagesRead}`;
trackByRelatedSeriesIdentiy = (index: number, item: RelatedSeris) => `${item.series.name}_${item.series.libraryId}_${item.series.pagesRead}_${item.relation}`;
trackByStoryLineIdentity = (index: number, item: StoryLineItem) => {
if (item.isChapter) {
return this.trackByChapterIdentity(index, item!.chapter!)
}
return this.trackByVolumeIdentity(index, item!.volume!);
};
/**
* Are there any related series
@ -120,6 +139,20 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
*/
relations: Array<RelatedSeris> = [];
sortingOptions: Array<{value: string, text: string}> = [
{value: 'Storyline', text: 'Storyline'},
{value: 'Release', text: 'Release'},
{value: 'Added', text: 'Added'},
];
renderMode: PageLayoutMode = PageLayoutMode.Cards;
pageExtrasGroup = new FormGroup({
'sortingOption': new FormControl(this.sortingOptions[0].value, []),
'renderMode': new FormControl(this.renderMode, []),
});
isAscendingSort: boolean = false; // TODO: Get this from User preferences
bulkActionCallback = (action: Action, data: any) => {
if (this.series === undefined) {
return;
@ -167,22 +200,27 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
private onDestroy: Subject<void> = new Subject();
get LibraryType(): typeof LibraryType {
get LibraryType() {
return LibraryType;
}
get MangaFormat(): typeof MangaFormat {
get MangaFormat() {
return MangaFormat;
}
get TagBadgeCursor(): typeof TagBadgeCursor {
get TagBadgeCursor() {
return TagBadgeCursor;
}
get TabID(): typeof TabID {
get TabID() {
return TabID;
}
get PageLayoutMode() {
return PageLayoutMode;
}
constructor(private route: ActivatedRoute, private seriesService: SeriesService,
private router: Router, public bulkSelectionService: BulkSelectionService,
private modalService: NgbModal, public readerService: ReaderService,
@ -200,10 +238,28 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadingRole = this.accountService.hasDownloadRole(user);
this.renderMode = user.preferences.globalPageLayoutMode;
this.pageExtrasGroup.get('renderMode')?.setValue(this.renderMode);
}
});
}
onScroll(): void {
const tabs = document.querySelector('.nav-tabs') as HTMLElement | null;
const main = document.querySelector('.main-container') as HTMLElement | null;
const content = document.querySelector('.tab-content') as HTMLElement | null;
let mainOffset = main!.offsetTop;
let tabOffset = tabs!.offsetTop;
let contentOffset = content!.offsetTop;
let mainScrollPos = main!.scrollTop;
if (!document.querySelector('.nav-tabs.fixed') && (tabOffset - mainOffset) <= mainScrollPos) {
tabs!.classList.add("fixed");
} else if (document.querySelector('.nav-tabs.fixed') && mainScrollPos <= (contentOffset - mainOffset)) {
tabs!.classList.remove("fixed");
}
}
ngOnInit(): void {
const routeId = this.route.snapshot.paramMap.get('seriesId');
const libraryId = this.route.snapshot.paramMap.get('libraryId');
@ -223,7 +279,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent;
if (seriesCoverUpdatedEvent.seriesId === this.seriesId) {
this.loadSeries(this.seriesId);
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.seriesId)); // NOTE: Is this needed as cover update will update the image for us
}
}
});
@ -232,6 +287,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.libraryId = parseInt(libraryId, 10);
this.seriesImage = this.imageService.getSeriesCoverImage(this.seriesId);
this.loadSeries(this.seriesId);
this.pageExtrasGroup.get('renderMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe((val: PageLayoutMode) => {
this.renderMode = val;
});
}
ngOnDestroy() {
@ -290,6 +349,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series, () => this.actionInProgress = false);
break;
case (Action.Download):
if (this.downloadInProgress) return;
this.downloadSeries();
break;
default:
break;
}
@ -358,12 +421,13 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
this.setContinuePoint();
forkJoin([
this.libraryService.getLibraryType(this.libraryId),
this.seriesService.getSeries(seriesId)
]).subscribe(results => {
this.libraryType = results[0];
this.series = results[1];
forkJoin({
libType: this.libraryService.getLibraryType(this.libraryId),
series: this.seriesService.getSeries(seriesId)
}).subscribe(results => {
this.libraryType = results.libType;
console.log('library type: ', this.libraryType);
this.series = results.series;
this.createHTML();
@ -371,6 +435,8 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit);
this.seriesActions.push({action: Action.Download, callback: this.seriesActions[0].callback, requiresAdmin: false, title: 'Download'});
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
@ -401,6 +467,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.chapters = detail.chapters;
this.volumes = detail.volumes;
this.storyChapters = detail.storylineChapters;
this.storylineItems = [];
const v = this.volumes.map(v => {
return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem;
});
this.storylineItems.push(...v);
const c = this.storyChapters.map(c => {
return {volume: undefined, chapter: c, isChapter: true} as StoryLineItem;
});
this.storylineItems.push(...c);
this.updateSelectedTab();
this.isLoading = false;
@ -530,9 +606,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.toastr.error('There are no chapters to this volume. Cannot read.');
return;
}
// NOTE: When selecting a volume, we might want to ask the user which chapter they want or an "Automatic" option. For Volumes
// made up of lots of chapter files, it makes it more versitile. The modal can have pages read / pages with colored background
// to help the user make a good choice.
// If user has progress on the volume, load them where they left off
if (volume.pagesRead < volume.pages && volume.pagesRead > 0) {
@ -573,15 +646,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
this.loadSeries(this.seriesId);
if (closeResult.coverImageUpdate) {
// Random triggers a load change without any problems with API
this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.seriesId));
}
}
});
}
async promptToReview() {
// TODO: After a review has been set, we might just want to show an edit icon next to star rating which opens the review, instead of prompting each time.
const shouldPrompt = this.isNullOrEmpty(this.series.userReview);
const config = new ConfirmConfig();
config.header = 'Confirm';
@ -631,15 +701,15 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
}
formatChapterTitle(chapter: Chapter) {
return this.utilityService.formatChapterName(this.libraryType, true, true) + chapter.range;
}
updateSortOrder() {
this.isAscendingSort = !this.isAscendingSort;
// if (this.filter.sortOptions === null) {
// this.filter.sortOptions = {
// isAscending: this.isAscendingSort,
// sortField: SortField.SortName
// }
// }
formatVolumeTitle(volume: Volume) {
if (this.libraryType === LibraryType.Book) {
return volume.name;
}
return 'Volume ' + volume.name;
// this.filter.sortOptions.isAscending = this.isAscendingSort;
}
}

View file

@ -1,7 +1,7 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SeriesDetailRoutingModule } from './series-detail-routing.module';
import { NgbCollapseModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbCollapseModule, NgbNavModule, NgbRatingModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { SeriesDetailComponent } from './series-detail.component';
import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component';
import { ReviewSeriesModalComponent } from './review-series-modal/review-series-modal.component';
@ -17,7 +17,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
declarations: [
SeriesDetailComponent,
ReviewSeriesModalComponent,
SeriesMetadataDetailComponent
SeriesMetadataDetailComponent,
],
imports: [
CommonModule,
@ -26,6 +26,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
NgbCollapseModule, // Series Metadata
NgbNavModule,
NgbRatingModule,
NgbTooltipModule, // Series Detail, Extras Drawer
TypeaheadModule,
PipeModule,

View file

@ -2,108 +2,7 @@
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
</div>
<!-- This first row will have random information about the series-->
<div class="row g-0 mb-4 mt-3">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Age Rating" [clickable]="true" fontClasses="fas fa-eye" (click)="goTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" title="Age Rating">
{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series">
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Release Year" [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="seriesMetadata.language !== null">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-3">
<app-icon-and-title label="Language" [clickable]="true" fontClasses="fas fa-language" (click)="goTo(FilterQueryParam.Languages, seriesMetadata.language)" title="Language">
{{seriesMetadata.language | defaultValue:'en' | languageName | async}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<ng-container *ngIf="seriesMetadata.publicationStatus | publicationStatus as pubStatus">
<app-icon-and-title label="Publication" [clickable]="true" fontClasses="fa-solid fa-hourglass-{{pubStatus === 'Ongoing' ? 'empty' : 'end'}}" (click)="goTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})">
{{pubStatus}}
</app-icon-and-title>
</ng-container>
</div>
<div class="vr m-2 d-none d-lg-block"></div>
</ng-container>
<ng-container>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Format" [clickable]="true" [fontClasses]="'fa ' + utilityService.mangaFormatIcon(series.format)" (click)="goTo(FilterQueryParam.Format, series.format)" title="Format">
{{utilityService.mangaFormat(series.format)}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Last Read" [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
{{series.latestReadDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB; else showPages">
<ng-container *ngIf="series.wordCount > 0">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Word Count" [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-container>
</ng-container>
<ng-template #showPages>
<div class="d-none d-md-block col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Print Length" [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{series.pages | number:''}} Pages
</app-icon-and-title>
</div>
<div class="vr d-none d-lg-block m-2"></div>
</ng-template>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Time" [clickable]="false" fontClasses="fa-regular fa-clock">
{{readingTime.minHours}}{{readingTime.maxHours !== readingTime.minHours ? ('-' + readingTime.maxHours) : ''}} Hour{{readingTime.minHours > 1 ? 's' : ''}}
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="readingTimeLeft.hasProgress && readingTimeLeft.avgHours !== 0 ">
<div class="vr d-none d-lg-block m-2"></div>
<div class="col-lg-1 col-md-4 col-sm-4 col-4 mb-2">
<app-icon-and-title label="Read Left" [clickable]="false" fontClasses="fa-solid fa-clock">
~{{readingTimeLeft.avgHours}} Hour{{readingTimeLeft.avgHours > 1 ? 's' : ''}} Left
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
<div class="row g-0" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
<div class="col-md-4">
@ -300,4 +199,7 @@
<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}} me-1" aria-controls="extended-series-metadata"></i>
See {{isCollapsed ? 'More' : 'Less'}}
</a>
</div>
</div>
<!-- This first row will have random information about the series-->
<app-series-info-cards [series]="series" [seriesMetadata]="seriesMetadata" (goTo)="handleGoTo($event)" [hasReadingProgress]="hasReadingProgress"></app-series-info-cards>

View file

@ -1,7 +1,7 @@
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
import { Router } from '@angular/router';
import { HourEstimateRange } from 'src/app/_models/hour-estimate-range';
import { MAX_WORDS_PER_HOUR, MIN_WORDS_PER_HOUR, MIN_PAGES_PER_MINUTE, MAX_PAGES_PER_MINUTE, ReaderService } from 'src/app/_services/reader.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { UtilityService } from '../../shared/_services/utility.service';
@ -20,6 +20,7 @@ import { MetadataService } from '../../_services/metadata.service';
export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
@Input() seriesMetadata!: SeriesMetadata;
@Input() hasReadingProgress: boolean = false;
/**
* Reading lists with a connection to the Series
*/
@ -29,8 +30,8 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
isCollapsed: boolean = true;
hasExtendedProperites: boolean = false;
readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1, hasProgress: false};
// readingTime: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
// readingTimeLeft: HourEstimateRange = {maxHours: 1, minHours: 1, avgHours: 1};
/**
* Html representation of Series Summary
@ -67,11 +68,6 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
if (this.seriesMetadata !== null) {
this.seriesSummary = (this.seriesMetadata.summary === null ? '' : this.seriesMetadata.summary).replace(/\n/g, '<br>');
}
if (this.series !== null) {
this.readerService.getTimeLeft(this.series.id).subscribe((timeLeft) => this.readingTimeLeft = timeLeft);
this.readerService.getTimeToRead(this.series.id).subscribe((time) => this.readingTime = time);
}
}
ngOnInit(): void {
@ -81,6 +77,10 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
this.isCollapsed = !this.isCollapsed;
}
handleGoTo(event: {queryParamName: FilterQueryParam, filter: any}) {
this.goTo(event.queryParamName, event.filter);
}
goTo(queryParamName: FilterQueryParam, filter: any) {
let params: any = {};
params[queryParamName] = filter;

View file

@ -71,10 +71,11 @@ export class FilterUtilitiesService {
/**
* Will fetch current page from route if present
* @param ActivatedRouteSnapshot to fetch page from. Must be from component else may get stale data
* @param itemsPerPage If you want pagination, pass non-zero number
* @returns A default pagination object
*/
pagination(snapshot: ActivatedRouteSnapshot): Pagination {
return {currentPage: parseInt(snapshot.queryParamMap.get('page') || '1', 10), itemsPerPage: 30, totalItems: 0, totalPages: 1};
pagination(snapshot: ActivatedRouteSnapshot, itemsPerPage: number = 0): Pagination {
return {currentPage: parseInt(snapshot.queryParamMap.get('page') || '1', 10), itemsPerPage, totalItems: 0, totalPages: 1};
}

View file

@ -1,18 +1,3 @@
<!-- <div class="circular">
<div class="inner"></div>
<div class="number">
<span class="visually-hidden">{{currentValue}}%</span>
<i class="fa fa-angle-double-down" style="font-size: 36px;" aria-hidden="true"></i>
</div>
<div class="circle">
<div class="bar left" #left>
<div class="progress"></div>
</div>
<div class="bar right" #right>
<div class="progress"></div>
</div>
</div>
</div> -->
<ng-container *ngIf="currentValue > 0">
<div class="number">
@ -28,11 +13,11 @@
[backgroundPadding]="0"
outerStrokeLinecap="butt"
[outerStrokeColor]="'#4ac694'"
[innerStrokeColor]="'transparent'"
[innerStrokeColor]="innerStrokeColor"
titleFontSize= "24"
unitsFontSize= "24"
[showSubtitle] = "false"
[animation]="true"
[animation]="animation"
[animationDuration]="300"
[startFromZero]="false"
[responsive]="true"

View file

@ -1,4 +1,4 @@
import { Component, ElementRef, Input, OnChanges, OnInit, Renderer2, ViewChild } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-circular-loader',
@ -9,8 +9,10 @@ export class CircularLoaderComponent implements OnInit {
@Input() currentValue: number = 0;
@Input() maxValue: number = 0;
@Input() animation: boolean = true;
@Input() innerStrokeColor: string = 'transparent';
constructor(private renderer: Renderer2) { }
constructor() { }
ngOnInit(): void {
}

View file

@ -1,3 +1,5 @@
<img #img class="lazyload" [src]="imageService.placeholderImage" [attr.data-src]="imageUrl"
(error)="imageService.updateErroredImage($event)"
aria-hidden="true">
aria-hidden="true">
<!-- TODO: Need to think about how to not lazyload but also handle broken images-->
<!-- <img #img [src]="imageUrl" aria-hidden="true"> -->

View file

@ -1,3 +1,4 @@
img {
width: 100%;
}
}

View file

@ -1,4 +1,4 @@
import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { Component, ElementRef, Input, OnChanges, OnDestroy, Renderer2, ViewChild } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CoverUpdateEvent } from 'src/app/_models/events/cover-update-event';
@ -39,6 +39,10 @@ export class ImageComponent implements OnChanges, OnDestroy {
* Border Radius of the image. If not defined, will not be applied
*/
@Input() borderRadius: string = '';
/**
* If the image component should respond to cover updates
*/
@Input() processEvents: boolean = true;
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
@ -46,27 +50,28 @@ export class ImageComponent implements OnChanges, OnDestroy {
constructor(public imageService: ImageService, private renderer: Renderer2, private hubService: MessageHubService) {
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe(res => {
if (res.event === EVENTS.CoverUpdate) {
const updateEvent = res.payload as CoverUpdateEvent;
if (this.imageUrl === undefined || this.imageUrl === null || this.imageUrl === '') return;
const enityType = this.imageService.getEntityTypeFromUrl(this.imageUrl);
if (enityType === updateEvent.entityType) {
const tokens = this.imageUrl.split('?')[1].split('&');
if (!this.processEvents) return;
if (res.event === EVENTS.CoverUpdate) {
const updateEvent = res.payload as CoverUpdateEvent;
if (this.imageUrl === undefined || this.imageUrl === null || this.imageUrl === '') return;
const enityType = this.imageService.getEntityTypeFromUrl(this.imageUrl);
if (enityType === updateEvent.entityType) {
const tokens = this.imageUrl.split('?')[1].split('&');
//...seriesId=123&random=
let id = tokens[0].replace(enityType + 'Id=', '');
if (id.includes('&')) {
id = id.split('&')[0];
}
if (id === (updateEvent.id + '')) {
this.imageUrl = this.imageService.randomize(this.imageUrl);
}
//...seriesId=123&random=
let id = tokens[0].replace(enityType + 'Id=', '');
if (id.includes('&')) {
id = id.split('&')[0];
}
if (id === (updateEvent.id + '')) {
this.imageUrl = this.imageService.randomize(this.imageUrl);
}
}
});
}
});
}
ngOnChanges(changes: SimpleChanges): void {
ngOnChanges(): void {
if (this.width != '') {
this.renderer.setStyle(this.imgElem.nativeElement, 'width', this.width);
}

View file

@ -7,7 +7,10 @@
<ng-content select="[main]"></ng-content>
</div>
<div>
<!-- This can get out of sync with offcanvas component. We might need a service to handle communication and service resets to collapsed on route changes -->
<button *ngIf="hasExtras" class="btn btn-secondary btn-small" (click)="openExtrasDrawer()" [attr.aria-expanded]="isExtrasOpen" placement="left" ngbTooltip="Page Settings">
<i class="fa-solid fa-sliders" aria-hidden="true"></i>
<span class="visually-hidden">Page Settings</span>
</button>
<button *ngIf="hasFilter" class="btn btn-{{filterActive ? 'primary' : 'secondary'}} btn-small" (click)="toggleService.toggle()" [attr.aria-expanded]="filterOpen" placement="left" ngbTooltip="{{filterOpen ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filterOpen ? 'Open' : 'Close'}} Filtering and Sorting">
<i class="fa fa-filter" aria-hidden="true"></i>
<span class="visually-hidden">Sort / Filter</span>

View file

@ -1,4 +1,5 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, TemplateRef } from '@angular/core';
import { NgbOffcanvas } from '@ng-bootstrap/ng-bootstrap';
import { Subject, takeUntil } from 'rxjs';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { NavService } from 'src/app/_services/nav.service';
@ -18,6 +19,10 @@ export class SideNavCompanionBarComponent implements OnInit, OnDestroy {
* If the page should show a filter
*/
@Input() hasFilter: boolean = false;
/**
* If the page should show an extra section button
*/
@Input() hasExtras: boolean = false;
/**
* Is the input open by default
@ -29,18 +34,18 @@ export class SideNavCompanionBarComponent implements OnInit, OnDestroy {
*/
@Input() filterActive: boolean = false;
/**
* Should be passed through from Filter component.
*/
//@Input() filterDisabled: EventEmitter<boolean> = new EventEmitter();
@Input() extraDrawer!: TemplateRef<any>;
@Output() filterOpen: EventEmitter<boolean> = new EventEmitter();
isFilterOpen = false;
isExtrasOpen = false;
private onDestroy: Subject<void> = new Subject();
constructor(private navService: NavService, private utilityService: UtilityService, public toggleService: ToggleService) {
constructor(private navService: NavService, private utilityService: UtilityService, public toggleService: ToggleService,
private offcanvasService: NgbOffcanvas) {
}
ngOnInit(): void {
@ -65,4 +70,13 @@ export class SideNavCompanionBarComponent implements OnInit, OnDestroy {
this.filterOpen.emit(this.isFilterOpen);
}
openExtrasDrawer() {
if (this.extraDrawer === undefined) return;
this.isExtrasOpen = true;
const drawerRef = this.offcanvasService.open(this.extraDrawer, {position: 'end', scroll: true});
drawerRef.closed.subscribe(() => this.isExtrasOpen = false);
drawerRef.dismissed.subscribe(() => this.isExtrasOpen = false);
}
}

View file

@ -16,213 +16,240 @@
<form [formGroup]="settingsForm" *ngIf="user !== undefined">
<ngb-accordion [closeOthers]="true" [activeIds]="AccordionPanelID.ImageReader" #acc="ngbAccordion">
<ngb-panel [id]="AccordionPanelID.ImageReader" title="Image Reader">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.ImageReader)" aria-controls="collapseOne">
Image Reader
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-reading-direction" class="form-label">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #readingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="visually-hidden" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-select" aria-describedby="manga-header" formControlName="readingDirection" id="settings-reading-direction">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-scaling-option" class="form-label">Scaling Options</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How to scale the image to your screen.</ng-template>
<span class="visually-hidden" id="settings-scaling-option-help">How to scale the image to your screen.</span>
<select class="form-select" aria-describedby="manga-header" formControlName="scalingOption" id="settings-scaling-option">
<option *ngFor="let opt of scalingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-pagesplit-option" class="form-label">Page Splitting</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
<ng-template #pageSplitOptionTooltip>How to split a full width image (ie both left and right images are combined)</ng-template>
<span class="visually-hidden" id="settings-pagesplit-option-help">How to split a full width image (ie both left and right images are combined)</span>
<select class="form-select" aria-describedby="manga-header" formControlName="pageSplitOption" id="settings-pagesplit-option">
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-readingmode-option" class="form-label">Reading Mode</label>
<select class="form-select" aria-describedby="manga-header" formControlName="readerMode" id="settings-readingmode-option">
<option *ngFor="let opt of readingModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2" *ngIf="false">
<label for="settings-layoutmode-option" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>Render a single image to the screen to two side-by-side images</ng-template>
<span class="visually-hidden" id="settings-layoutmode-option-help"><ng-container [ngTemplateOutlet]="layoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="manga-header" formControlName="layoutMode" id="settings-layoutmode-option">
<option *ngFor="let opt of layoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-backgroundcolor-option" class="form-label">Background Color</label>
<input [value]="user.preferences.backgroundColor"
class="form-control"
(colorPickerChange)="settingsForm.markAsTouched()"
[style.background]="user.preferences.backgroundColor"
[cpAlphaChannel]="'disabled'"
[(colorPicker)]="user.preferences.backgroundColor"/>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="show-screen-hints" role="switch" formControlName="showScreenHints" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="show-screen-hints">Show Screen Hints</label>
</div>
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</ng-template>
</ngb-panel>
<ngb-panel [id]="AccordionPanelID.BookReader" title="Book Reader">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.BookReader)" aria-controls="collapseOne">
Book Reader
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<ngb-panel [id]="AccordionPanelID.GlobalSettings" title="Global Settings">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.GlobalSettings)" aria-controls="collapseOne">
Global Settings
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="taptopaginate-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="taptopaginate" formControlName="bookReaderTapToPaginate" class="form-check-input" [value]="true" aria-labelledby="taptopaginate-label">
<label id="taptopaginate" class="form-check-label">Tap to Paginate</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tapToPaginateOptionTooltip" role="button" tabindex="0"></i>
<ng-template #tapToPaginateOptionTooltip>Should the sides of the book reader screen allow tapping on it to move to prev/next page</ng-template>
<span class="visually-hidden" id="settings-taptopaginate-option-help">Should the sides of the book reader screen allow tapping on it to move to prev/next page</span>
</div>
</div>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="immersivemode-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="immersivemode" formControlName="bookReaderImmersiveMode" class="form-check-input" [value]="true" aria-labelledby="immersivemode-label">
<label id="immersivemode" class="form-check-label">Immersive Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="immersivemodeOptionTooltip" role="button" tabindex="0"></i>
<ng-template #immersivemodeOptionTooltip>This will hide the menu behind a click on the reader document and turn tap to paginate on</ng-template>
<span class="visually-hidden" id="settings-immersivemode-option-help">This will hide the menu behind a click on the reader document and turn tap to paginate on</span>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-reading-direction" class="form-label">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReadingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReadingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="visually-hidden" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-select" aria-describedby="settings-reading-direction-help" formControlName="bookReaderReadingDirection">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-global-layoutmode" class="form-label">Page Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>Show items as cards or list view on Series Detail page</ng-template>
<span class="visually-hidden" id="settings-global-layoutmode-help">Show items as cards or list view on Series Detail page</span>
<select class="form-select" aria-describedby="manga-header" formControlName="globalPageLayoutMode" id="settings-global-layoutmode">
<option *ngFor="let opt of pageLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-fontfamily-option" class="form-label">Font Family</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
<ng-template #fontFamilyOptionTooltip>Font familty to load up. Default will load the book's default font</ng-template>
<span class="visually-hidden" id="settings-fontfamily-option-help">Font familty to load up. Default will load the book's default font</span>
<select class="form-select" aria-describedby="settings-fontfamily-option-help" formControlName="bookReaderFontFamily">
<option *ngFor="let opt of fontFamilies" [value]="opt">{{opt | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-layout-mode" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLayoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #bookLayoutModeTooltip>How content should be laid out. Default is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page</ng-template>
<span class="visually-hidden" id="settings-book-layout-mode-help"><ng-container [ngTemplateOutlet]="bookLayoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-book-layout-mode-help" formControlName="bookReaderLayoutMode" id="settings-book-layout-mode">
<option *ngFor="let opt of bookLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-color-theme-option" class="form-label">Color Theme</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookColorThemeTooltip" role="button" tabindex="0"></i>
<ng-template #bookColorThemeTooltip>What color theme to apply to the book reader content and menuing</ng-template>
<span class="visually-hidden" id="settings-color-theme-option-help"><ng-container [ngTemplateOutlet]="bookColorThemeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-color-theme-option-help" formControlName="bookReaderThemeName" id="settings-color-theme-option">
<option *ngFor="let opt of bookColorThemes" [value]="opt.name">{{opt.name | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label for="fontsize" class="form-label range-label">Font Size</label>
<input type="range" class="form-range" id="fontsize"
min="50" max="300" step="10" formControlName="bookReaderFontSize">
<span class="range-text">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label" for="linespacing">Line Height</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>How much spacing between the lines of the book</ng-template>
<span class="visually-hidden" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
</div>
<input type="range" class="form-range" id="linespacing" min="100" max="200" step="10"
formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help">
<span class="range-text">{{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label">Margin</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
<span class="visually-hidden" id="settings-bookmargin-option-help">How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</span>
</div>
<input type="range" class="form-range" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" aria-describedby="bookmargin">
<span class="range-text">{{settingsForm.get('bookReaderMargin')?.value + '%'}}</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</ng-template>
</ngb-panel>
</ng-template>
</ngb-panel>
<ngb-panel [id]="AccordionPanelID.ImageReader" title="Image Reader">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.ImageReader)" aria-controls="collapseOne">
Image Reader
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-reading-direction" class="form-label">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="readingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #readingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="visually-hidden" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-select" aria-describedby="manga-header" formControlName="readingDirection" id="settings-reading-direction">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-scaling-option" class="form-label">Scaling Options</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="taskBackupTooltip" role="button" tabindex="0"></i>
<ng-template #taskBackupTooltip>How to scale the image to your screen.</ng-template>
<span class="visually-hidden" id="settings-scaling-option-help">How to scale the image to your screen.</span>
<select class="form-select" aria-describedby="manga-header" formControlName="scalingOption" id="settings-scaling-option">
<option *ngFor="let opt of scalingOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-pagesplit-option" class="form-label">Page Splitting</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="pageSplitOptionTooltip" role="button" tabindex="0"></i>
<ng-template #pageSplitOptionTooltip>How to split a full width image (ie both left and right images are combined)</ng-template>
<span class="visually-hidden" id="settings-pagesplit-option-help">How to split a full width image (ie both left and right images are combined)</span>
<select class="form-select" aria-describedby="manga-header" formControlName="pageSplitOption" id="settings-pagesplit-option">
<option *ngFor="let opt of pageSplitOptions" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-readingmode-option" class="form-label">Reading Mode</label>
<select class="form-select" aria-describedby="manga-header" formControlName="readerMode" id="settings-readingmode-option">
<option *ngFor="let opt of readingModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2" *ngIf="false">
<label for="settings-layoutmode-option" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="layoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #layoutModeTooltip>Render a single image to the screen to two side-by-side images</ng-template>
<span class="visually-hidden" id="settings-layoutmode-option-help"><ng-container [ngTemplateOutlet]="layoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="manga-header" formControlName="layoutMode" id="settings-layoutmode-option">
<option *ngFor="let opt of layoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<label for="settings-backgroundcolor-option" class="form-label">Background Color</label>
<input [value]="user.preferences.backgroundColor"
class="form-control"
(colorPickerChange)="settingsForm.markAsTouched()"
[style.background]="user.preferences.backgroundColor"
[cpAlphaChannel]="'disabled'"
[(colorPicker)]="user.preferences.backgroundColor"/>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="autoCloseMenu" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-2">
<div class="mb-3 mt-1">
<div class="form-check form-switch">
<input type="checkbox" id="show-screen-hints" role="switch" formControlName="showScreenHints" class="form-check-input" [value]="true" aria-labelledby="auto-close-label">
<label class="form-check-label" for="show-screen-hints">Show Screen Hints</label>
</div>
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</ng-template>
</ngb-panel>
<ngb-panel [id]="AccordionPanelID.BookReader" title="Book Reader">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded(AccordionPanelID.BookReader)" aria-controls="collapseOne">
Book Reader
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="taptopaginate-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="taptopaginate" formControlName="bookReaderTapToPaginate" class="form-check-input" [value]="true" aria-labelledby="taptopaginate-label">
<label id="taptopaginate" class="form-check-label">Tap to Paginate</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="tapToPaginateOptionTooltip" role="button" tabindex="0"></i>
<ng-template #tapToPaginateOptionTooltip>Should the sides of the book reader screen allow tapping on it to move to prev/next page</ng-template>
<span class="visually-hidden" id="settings-taptopaginate-option-help">Should the sides of the book reader screen allow tapping on it to move to prev/next page</span>
</div>
</div>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label id="immersivemode-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" role="switch" id="immersivemode" formControlName="bookReaderImmersiveMode" class="form-check-input" [value]="true" aria-labelledby="immersivemode-label">
<label id="immersivemode" class="form-check-label">Immersive Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="immersivemodeOptionTooltip" role="button" tabindex="0"></i>
<ng-template #immersivemodeOptionTooltip>This will hide the menu behind a click on the reader document and turn tap to paginate on</ng-template>
<span class="visually-hidden" id="settings-immersivemode-option-help">This will hide the menu behind a click on the reader document and turn tap to paginate on</span>
</div>
</div>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-reading-direction" class="form-label">Reading Direction</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReadingDirectionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReadingDirectionTooltip>Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</ng-template>
<span class="visually-hidden" id="settings-reading-direction-help">Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.</span>
<select class="form-select" aria-describedby="settings-reading-direction-help" formControlName="bookReaderReadingDirection">
<option *ngFor="let opt of readingDirections" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-fontfamily-option" class="form-label">Font Family</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="fontFamilyOptionTooltip" role="button" tabindex="0"></i>
<ng-template #fontFamilyOptionTooltip>Font familty to load up. Default will load the book's default font</ng-template>
<span class="visually-hidden" id="settings-fontfamily-option-help">Font familty to load up. Default will load the book's default font</span>
<select class="form-select" aria-describedby="settings-fontfamily-option-help" formControlName="bookReaderFontFamily">
<option *ngFor="let opt of fontFamilies" [value]="opt">{{opt | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-book-layout-mode" class="form-label">Layout Mode</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLayoutModeTooltip" role="button" tabindex="0"></i>
<ng-template #bookLayoutModeTooltip>How content should be laid out. Default is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page</ng-template>
<span class="visually-hidden" id="settings-book-layout-mode-help"><ng-container [ngTemplateOutlet]="bookLayoutModeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-book-layout-mode-help" formControlName="bookReaderLayoutMode" id="settings-book-layout-mode">
<option *ngFor="let opt of bookLayoutModes" [value]="opt.value">{{opt.text | titlecase}}</option>
</select>
</div>
<div class="col-md-6 col-sm-12 pe-2 mb-3">
<label for="settings-color-theme-option" class="form-label">Color Theme</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookColorThemeTooltip" role="button" tabindex="0"></i>
<ng-template #bookColorThemeTooltip>What color theme to apply to the book reader content and menuing</ng-template>
<span class="visually-hidden" id="settings-color-theme-option-help"><ng-container [ngTemplateOutlet]="bookColorThemeTooltip"></ng-container></span>
<select class="form-select" aria-describedby="settings-color-theme-option-help" formControlName="bookReaderThemeName" id="settings-color-theme-option">
<option *ngFor="let opt of bookColorThemes" [value]="opt.name">{{opt.name | titlecase}}</option>
</select>
</div>
</div>
<div class="row g-0">
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<label for="fontsize" class="form-label range-label">Font Size</label>
<input type="range" class="form-range" id="fontsize"
min="50" max="300" step="10" formControlName="bookReaderFontSize">
<span class="range-text">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label" for="linespacing">Line Height</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookLineHeightOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookLineHeightOptionTooltip>How much spacing between the lines of the book</ng-template>
<span class="visually-hidden" id="settings-booklineheight-option-help">How much spacing between the lines of the book</span>
</div>
<input type="range" class="form-range" id="linespacing" min="100" max="200" step="10"
formControlName="bookReaderLineSpacing" aria-describedby="settings-booklineheight-option-help">
<span class="range-text">{{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
</div>
<div class="col-md-4 col-sm-12 pe-2 mb-3">
<div class="range-label">
<label class="form-label">Margin</label>&nbsp;<i class="fa fa-info-circle" aria-hidden="true" placement="right" [ngbTooltip]="bookReaderMarginOptionTooltip" role="button" tabindex="0"></i>
<ng-template #bookReaderMarginOptionTooltip>How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</ng-template>
<span class="visually-hidden" id="settings-bookmargin-option-help">How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.</span>
</div>
<input type="range" class="form-range" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" aria-describedby="bookmargin">
<span class="range-text">{{settingsForm.get('bookReaderMargin')?.value + '%'}}</span>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.touched && !settingsForm.dirty">Save</button>
</div>
</ng-template>
</ngb-panel>
</ngb-accordion>
</form>
</ng-container>

View file

@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr';
import { map, shareReplay, take, takeUntil } from 'rxjs/operators';
import { Title } from '@angular/platform-browser';
import { BookService } from 'src/app/book-reader/book.service';
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes } from 'src/app/_models/preferences/preferences';
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes, pageLayoutModes } from 'src/app/_models/preferences/preferences';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { ActivatedRoute, Router } from '@angular/router';
@ -15,7 +15,8 @@ import { forkJoin, Observable, of, Subject } from 'rxjs';
enum AccordionPanelID {
ImageReader = 'image-reader',
BookReader = 'book-reader'
BookReader = 'book-reader',
GlobalSettings = 'global-settings'
}
@Component({
@ -32,6 +33,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
layoutModes = layoutModes;
bookLayoutModes = bookLayoutModes;
bookColorThemes = bookColorThemes;
pageLayoutModes = pageLayoutModes;
settingsForm: FormGroup = new FormGroup({});
passwordChangeForm: FormGroup = new FormGroup({});
@ -120,6 +122,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user?.preferences.bookReaderImmersiveMode, []));
this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, []));
this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, []));
});
this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required]));
@ -165,6 +168,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.get('bookReaderThemeName')?.setValue(this.user.preferences.bookReaderThemeName);
this.settingsForm.get('theme')?.setValue(this.user.preferences.theme);
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode);
this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode);
}
resetPasswordForm() {
@ -184,7 +188,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
readerMode: parseInt(modelSettings.readerMode, 10),
layoutMode: parseInt(modelSettings.layoutMode, 10),
showScreenHints: modelSettings.showScreenHints,
backgroundColor: modelSettings.backgroundColor, // this.user.preferences.backgroundColor,
backgroundColor: modelSettings.backgroundColor,
bookReaderFontFamily: modelSettings.bookReaderFontFamily,
bookReaderLineSpacing: modelSettings.bookReaderLineSpacing,
bookReaderFontSize: modelSettings.bookReaderFontSize,
@ -194,7 +198,8 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
bookReaderLayoutMode: parseInt(modelSettings.bookReaderLayoutMode, 10),
bookReaderThemeName: modelSettings.bookReaderThemeName,
theme: modelSettings.theme,
bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode
bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode,
globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10),
};
this.obserableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {