Word Count (#1286)

* Adding some code for Robbie

* See more on series detail metadata area is now at the bottom on the section

* Cleaned up subtitle headings to use a single class for offset with actionables

* Added some markup for the new design, waiting for Robbie to finish it off

* styling age-rating badge

* Started hooking up basic analyze file service and hooks in the UI. Basic code to implement the count is implemented and in benchmarks.

* Hooked up analyze ui to backend

* Refactored Series Detail metadata area to use a new icon/title design

* Cleaned up the new design

* Pushing for robbie to do css

* Massive performance improvement to scan series where we only need to scan folders reported that have series in them, rather than the whole library.

* Removed theme page as we no longer need it. Added WordCount to DTOs so the UI can show them. Added new pipe to format numbers in compact mode.

* Hooked up actual reading time based on user's words per hour

* Refactor some magic numbers to consts

* Hooked in progress reporting for series word count

* Hooked up analyze files

* Re-implemented time to read on comics

* Removed the word Last Read

* Show proper language name instead of iso tag on series detail page. Added some error handling on word count code.

* Reworked error handling

* Fixed some security vulnerabilities in npm.

* Handle a case where there are no text nodes and instead of returning an empty list, htmlagilitypack returns null.

* Tweaked the styles a bit on the icon-and-title

* Code cleanup

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-05-25 16:53:39 -05:00 committed by GitHub
parent 0a70ac35dc
commit c1490d6e86
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2354 additions and 408 deletions

View file

@ -48,4 +48,8 @@ export interface Series {
* DateTime representing last time a chapter was added to the Series
*/
lastChapterAdded: string;
/**
* Number of words in the series
*/
wordCount: number;
}

View file

@ -9,20 +9,53 @@ import { Volume } from '../_models/volume';
import { AccountService } from './account.service';
export enum Action {
/**
* Mark entity as read
*/
MarkAsRead = 0,
/**
* Mark entity as unread
*/
MarkAsUnread = 1,
/**
* Invoke a Scan Library
*/
ScanLibrary = 2,
/**
* Delete the entity
*/
Delete = 3,
/**
* Open edit modal
*/
Edit = 4,
/**
* Open details modal
*/
Info = 5,
/**
* Invoke a refresh covers
*/
RefreshMetadata = 6,
/**
* Download the entity
*/
Download = 7,
/**
* @deprecated This is no longer supported. Use the dedicated page instead
* Invoke an Analyze Files which calculates word count
*/
AnalyzeFiles = 8,
/**
* Read in incognito mode aka no progress tracking
*/
Bookmarks = 8,
IncognitoRead = 9,
/**
* Add to reading list
*/
AddToReadingList = 10,
/**
* Add to collection
*/
AddToCollection = 11,
/**
* Essentially a download, but handled differently. Needed so card bubbles it up for handling
@ -31,7 +64,7 @@ export enum Action {
/**
* Open Series detail page for said series
*/
ViewSeries = 13
ViewSeries = 13,
}
export interface ActionItem<T> {
@ -97,6 +130,13 @@ export class ActionFactoryService {
requiresAdmin: true
});
this.seriesActions.push({
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.Delete,
title: 'Delete',
@ -131,6 +171,13 @@ export class ActionFactoryService {
callback: this.dummyCallback,
requiresAdmin: true
});
this.libraryActions.push({
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true
});
this.chapterActions.push({
action: Action.Edit,
@ -200,11 +247,6 @@ export class ActionFactoryService {
return actions;
}
filterBookmarksForFormat(action: ActionItem<Series>, series: Series) {
if (action.action === Action.Bookmarks && series?.format === MangaFormat.EPUB) return false;
return true;
}
dummyCallback(action: Action, data: any) {}
_resetActions() {

View file

@ -64,6 +64,7 @@ export class ActionService implements OnDestroy {
});
}
/**
* Request a refresh of Metadata for a given Library
* @param library Partial Library, must have id and name populated
@ -90,6 +91,32 @@ export class ActionService implements OnDestroy {
});
}
/**
* Request an analysis of files for a given Library (currently just word count)
* @param library Partial Library, must have id and name populated
* @param callback Optional callback to perform actions after API completes
* @returns
*/
async analyzeFiles(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
if (!await this.confirmService.alert('This is a long running process. Please give it the time to complete before invoking again.')) {
if (callback) {
callback(library);
}
return;
}
this.libraryService.analyze(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.info('Library file analysis queued for ' + library.name);
if (callback) {
callback(library);
}
});
}
/**
* Mark a series as read; updates the series pagesRead
* @param series Series, must have id and name populated
@ -121,7 +148,7 @@ export class ActionService implements OnDestroy {
}
/**
* Start a file scan for a Series (currently just does the library not the series directly)
* Start a file scan for a Series
* @param series Series, must have libraryId and name populated
* @param callback Optional callback to perform actions after API completes
*/
@ -134,6 +161,20 @@ export class ActionService implements OnDestroy {
});
}
/**
* Start a file scan for analyze files for a Series
* @param series Series, must have libraryId and name populated
* @param callback Optional callback to perform actions after API completes
*/
analyzeFilesForSeries(series: Series, callback?: SeriesActionCallback) {
this.seriesService.analyzeFiles(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.info('Scan queued for ' + series.name);
if (callback) {
callback(series);
}
});
}
/**
* Start a metadata refresh for a Series
* @param series Series, must have libraryId, id and name populated

View file

@ -74,6 +74,10 @@ export class LibraryService {
return this.httpClient.post(this.baseUrl + 'library/scan?libraryId=' + libraryId, {});
}
analyze(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/analyze?libraryId=' + libraryId, {});
}
refreshMetadata(libraryId: number) {
return this.httpClient.post(this.baseUrl + 'library/refresh-metadata?libraryId=' + libraryId, {});
}

View file

@ -20,6 +20,7 @@ export class MetadataService {
baseUrl = environment.apiUrl;
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
@ -81,7 +82,12 @@ export class MetadataService {
* All the potential language tags there can be
*/
getAllValidLanguages() {
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages');
if (this.validLanguages != undefined && this.validLanguages.length > 0) {
return of(this.validLanguages);
}
return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages').pipe(map(l => this.validLanguages = l));
//return this.httpClient.get<Array<Language>>(this.baseUrl + 'metadata/all-languages').pipe();
}
getAllPeople(libraries?: Array<number>) {

View file

@ -145,6 +145,10 @@ export class SeriesService {
return this.httpClient.post(this.baseUrl + 'series/scan', {libraryId: libraryId, seriesId: seriesId});
}
analyzeFiles(libraryId: number, seriesId: number) {
return this.httpClient.post(this.baseUrl + 'series/analyze', {libraryId: libraryId, seriesId: seriesId});
}
getMetadata(seriesId: number) {
return this.httpClient.get<SeriesMetadata>(this.baseUrl + 'series/metadata?seriesId=' + seriesId).pipe(map(items => {
items?.collectionTags.forEach(tag => tag.coverImage = this.imageService.getCollectionCoverImage(tag.id));

View file

@ -4,8 +4,6 @@ import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard';
import { AdminGuard } from './_guards/admin.guard';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
// TODO: Use Prefetching of LazyLoaded Modules
const routes: Routes = [
{
path: 'admin',
@ -72,13 +70,9 @@ const routes: Routes = [
},
]
},
{
path: 'theme',
loadChildren: () => import('../app/dev-only/dev-only.module').then(m => m.DevOnlyModule)
},
{path: 'login', loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule)},
{path: '**', pathMatch: 'full', redirectTo: 'libraries'},
{path: '', pathMatch: 'full', redirectTo: 'libraries'},
{path: '', pathMatch: 'full', redirectTo: 'login'},
];
@NgModule({

View file

@ -71,7 +71,7 @@ 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)).filter(action => this.actionFactoryService.filterBookmarksForFormat(action, 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));
}
}
@ -102,10 +102,13 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
this.openEditModal(series);
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series, (series) => {/* No Operation */ });
this.actionService.addSeriesToReadingList(series);
break;
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series], () => {/* No Operation */ });
this.actionService.addMultipleSeriesToCollectionTag([series]);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series);
break;
default:
break;

View file

@ -1,18 +0,0 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ThemeTestComponent } from './theme-test/theme-test.component';
const routes: Routes = [
{
path: '',
component: ThemeTestComponent,
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class DevOnlyRoutingModule { }

View file

@ -1,37 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { CardsModule } from '../cards/cards.module';
import { TypeaheadModule } from '../typeahead/typeahead.module';
import { ThemeTestComponent } from './theme-test/theme-test.component';
import { SharedModule } from '../shared/shared.module';
import { PipeModule } from '../pipe/pipe.module';
import { DevOnlyRoutingModule } from './dev-only-routing.module';
import { FormsModule } from '@angular/forms';
/**
* This module contains components that aren't meant to ship with main code. They are there to test things out. This module may be deleted in future updates.
*/
@NgModule({
declarations: [
ThemeTestComponent
],
imports: [
CommonModule,
FormsModule,
TypeaheadModule,
CardsModule,
NgbAccordionModule,
NgbNavModule,
SharedModule,
PipeModule,
DevOnlyRoutingModule
]
})
export class DevOnlyModule { }

View file

@ -1,188 +0,0 @@
<h1>Themes</h1>
<button class="btn btn-primary" (click)="themeService?.setTheme('dark')">Dark</button>
<button class="btn btn-primary" (click)="themeService?.setTheme('light')">Light</button>
<button class="btn btn-primary" (click)="themeService?.setTheme('E-Ink')">E-ink</button>
<button class="btn btn-primary" (click)="themeService?.setTheme('custom')">Custom</button>
<h2>Buttons</h2>
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">secondary</button>
<button class="btn btn-secondary alt">secondary alt</button>
<button class="btn btn-outline-primary">outline primary</button>
<button class="btn btn-outline-secondary">outline secondary</button>
<button class="btn btn-link">btn link</button>
<button class="btn btn-icon">
<i class="fa fa-angle-left"></i>&nbsp;Icon
</button>
<h2>Toastr</h2>
<button class="btn btn-primary" (click)="toastr.success('Test')">Success</button>
<button class="btn btn-danger" (click)="toastr.error('Test')">Error</button>
<button class="btn btn-secondary" (click)="toastr.warning('Test')">Warning</button>
<button class="btn btn-link" (click)="toastr.info('Test')">Info</button>
<h2>Inputs</h2>
<p>Inputs should always have class="form-control" on them</p>
<label>Normal</label>
<input type="text" class="form-control">
<label>Readonly</label>
<input type="text" readonly class="form-control">
<label>Placeholder</label>
<input type="text" placeholder="Hello, I'm a placeholder" class="form-control">
<label>Disabled</label>
<input type="text" placeholder="Hello, I'm a placeholder" [disabled]="true" class="form-control">
<h2>Checkbox</h2>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input">
<label for="stat-collection" class="form-check-label">Normal Checkbox</label>
</div>
</div>
<div class="mb-3">
<label for="stat-collection" class="form-label" aria-describedby="collection-info">Allow Anonymous Usage Collection</label>
<div class="form-check">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" [disabled]="true">
<label for="stat-collection" class="form-check-label">Disabled Checkbox</label>
</div>
</div>
<h2>Radio</h2>
<p>Labels should have form-check-label on them and inputs form-check-input</p>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="radio" id="site-dark-mode" [value]="true" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-dark-mode">True</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="site-not-dark-mode2" [value]="false" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-not-dark-mode2">False</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="site-not-dark-mode3" [disabled]="true" [value]="false" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-not-dark-mode3">Disabled</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" id="site-not-dark-mode4" readonly [value]="false" aria-labelledby="site-dark-mode-label">
<label class="form-check-label" for="site-not-dark-mode4">Readonly</label>
</div>
</div>
<h2>Nav tabs</h2>
<h3>Tabs</h3>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-tabs nav-pills">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink=".">{{ tab.title | sentenceCase }}</a>
<ng-template ngbNavContent>
<ng-container>
Tab 1
</ng-container>
<ng-container>
Tab 2
</ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<h3>Tabs</h3>
<nav role="navigation">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills justify-content-center mt-3" role="tab">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab" class="nav-item">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | titlecase }}</a>
<ng-template ngbNavContent>
<ng-container>
Tab 1
</ng-container>
<ng-container>
Tab 2
</ng-container>
</ng-template>
</li>
</ul>
</nav>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<h2>Tag Badge</h2>
<div class="g-2">
<app-tag-badge [selectionMode]="TagBadgeCursor.Selectable">Selectable</app-tag-badge>
<app-tag-badge [selectionMode]="TagBadgeCursor.Clickable">Clickable</app-tag-badge>
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed">Non Allowed</app-tag-badge>
</div>
<h2>Person Badge with Expander</h2>
<div class="g-2">
<app-person-badge></app-person-badge>
<app-badge-expander [items]="people" [itemsTillExpander]="1">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<h2>Switch</h2>
<form>
<div class="mb-3">
<label id="auto-close-label" class="form-label"></label>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" class="form-check-input" aria-labelledby="auto-close-label">
<label class="form-check-label" for="auto-close">Auto Close Menu</label>
</div>
</div>
</div>
</form>
<h2>Dropdown/List Group</h2>
<div class="dropdown" >
<ul class="list-group" role="listbox" id="dropdown">
<li class="list-group-item">Item 1</li>
<li class="list-group-item">Item 2</li>
</ul>
</div>
<h2>Accordion</h2>
<ngb-accordion [closeOthers]="true" activeIds="reading-panel" #acc="ngbAccordion">
<ngb-panel id="reading-panel" title="Reading">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('reading-panel')" aria-controls="collapseOne">
Reading
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<p>This is the body of the accordion...........This is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf asThis is the body of the accordion asdfasdf as</p>
</ng-template>
</ngb-panel>
<ngb-panel id="reading-panel2">
<ng-template ngbPanelHeader>
<h2 class="accordion-header">
<button class="accordion-button" ngbPanelToggle type="button" [attr.aria-expanded]="acc.isExpanded('reading-panel')" aria-controls="collapseOne">
Header 2
</button>
</h2>
</ng-template>
<ng-template ngbPanelContent>
<p>This is the body of the accordion asdfasdf as
dfas
f asdfasdfasdf asdfasdfaaff asdf
as fd
asfasf asdfasdfafd
</p>
</ng-template>
</ngb-panel>
</ngb-accordion>
<h2>Cards</h2>
<app-card-item [entity]="seriesNotRead"></app-card-item>
<app-card-item [entity]="seriesNotRead" [count]="10"></app-card-item>
<app-card-item [entity]="seriesWithProgress"></app-card-item>

View file

@ -1,84 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { TagBadgeCursor } from '../../shared/tag-badge/tag-badge.component';
import { ThemeService } from '../../_services/theme.service';
import { MangaFormat } from '../../_models/manga-format';
import { Person, PersonRole } from '../../_models/person';
import { Series } from '../../_models/series';
import { NavService } from '../../_services/nav.service';
@Component({
selector: 'app-theme-test',
templateUrl: './theme-test.component.html',
styleUrls: ['./theme-test.component.scss']
})
export class ThemeTestComponent implements OnInit {
tabs: Array<{title: string, fragment: string}> = [
{title: 'General', fragment: ''},
{title: 'Users', fragment: 'users'},
{title: 'Libraries', fragment: 'libraries'},
{title: 'System', fragment: 'system'},
{title: 'Changelog', fragment: 'changelog'},
];
active = this.tabs[0];
people: Array<Person> = [
{id: 1, name: 'Joe', role: PersonRole.Artist},
{id: 2, name: 'Joe 2', role: PersonRole.Artist},
];
seriesNotRead: Series = {
id: 1,
name: 'Test Series',
pages: 0,
pagesRead: 10,
format: MangaFormat.ARCHIVE,
libraryId: 1,
coverImageLocked: false,
created: '',
latestReadDate: '',
localizedName: '',
originalName: '',
sortName: '',
userRating: 0,
userReview: '',
volumes: [],
localizedNameLocked: false,
nameLocked: false,
sortNameLocked: false,
lastChapterAdded: '',
}
seriesWithProgress: Series = {
id: 1,
name: 'Test Series',
pages: 5,
pagesRead: 10,
format: MangaFormat.ARCHIVE,
libraryId: 1,
coverImageLocked: false,
created: '',
latestReadDate: '',
localizedName: '',
originalName: '',
sortName: '',
userRating: 0,
userReview: '',
volumes: [],
localizedNameLocked: false,
nameLocked: false,
sortNameLocked: false,
lastChapterAdded: '',
}
get TagBadgeCursor(): typeof TagBadgeCursor {
return TagBadgeCursor;
}
constructor(public toastr: ToastrService, public navService: NavService, public themeService: ThemeService) { }
ngOnInit(): void {
}
}

View file

@ -3,7 +3,7 @@
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
{{libraryName}}
</h2>
<h6 subtitle style="margin-left:40px;" *ngIf="active.fragment === ''">{{pagination?.totalItems}} Series</h6>
<h6 subtitle class="subtitle-with-actionables" *ngIf="active.fragment === ''">{{pagination?.totalItems}} Series</h6>
<div main>
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav nav-pills" style="flex-wrap: nowrap;">
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">

View file

@ -0,0 +1,18 @@
import { Pipe, PipeTransform } from '@angular/core';
const formatter = new Intl.NumberFormat('en-GB', {
//@ts-ignore
notation: 'compact' // https://github.com/microsoft/TypeScript/issues/36533
});
@Pipe({
name: 'compactNumber'
})
export class CompactNumberPipe implements PipeTransform {
transform(value: number): string {
return formatter.format(value);
}
}

View file

@ -5,8 +5,8 @@ import { Pipe, PipeTransform } from '@angular/core';
})
export class DefaultValuePipe implements PipeTransform {
transform(value: any): string {
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return '—';
transform(value: any, replacementString = '—'): string {
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return replacementString;
return value;
}

View file

@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core';
import { map, Observable } from 'rxjs';
import { MetadataService } from '../_services/metadata.service';
@Pipe({
name: 'languageName'
})
export class LanguageNamePipe implements PipeTransform {
constructor(private metadataService: MetadataService) {
}
transform(isoCode: string): Observable<string> {
return this.metadataService.getAllValidLanguages().pipe(map(lang => {
return lang.filter(l => l.isoCode === isoCode)[0].title;
}));
}
}

View file

@ -7,6 +7,8 @@ import { PersonRolePipe } from './person-role.pipe';
import { SafeHtmlPipe } from './safe-html.pipe';
import { RelationshipPipe } from './relationship.pipe';
import { DefaultValuePipe } from './default-value.pipe';
import { CompactNumberPipe } from './compact-number.pipe';
import { LanguageNamePipe } from './language-name.pipe';
@ -18,7 +20,9 @@ import { DefaultValuePipe } from './default-value.pipe';
SentenceCasePipe,
SafeHtmlPipe,
RelationshipPipe,
DefaultValuePipe
DefaultValuePipe,
CompactNumberPipe,
LanguageNamePipe
],
imports: [
CommonModule,
@ -30,7 +34,9 @@ import { DefaultValuePipe } from './default-value.pipe';
SentenceCasePipe,
SafeHtmlPipe,
RelationshipPipe,
DefaultValuePipe
DefaultValuePipe,
CompactNumberPipe,
LanguageNamePipe
]
})
export class PipeModule { }

View file

@ -5,7 +5,7 @@
</span>
{{readingList?.title}}&nbsp;<span *ngIf="readingList?.promoted">(<i class="fa fa-angle-double-up" aria-hidden="true"></i>)</span>
</h2>
<h6 subtitle style="margin-left: 40px">{{items.length}} Items</h6>
<h6 subtitle class="subtitle-with-actionables">{{items.length}} Items</h6>
</app-side-nav-companion-bar>
<div class="container-fluid mt-2" *ngIf="readingList">

View file

@ -6,7 +6,7 @@
</h2>
</ng-container>
<ng-container subtitle *ngIf="series?.localizedName !== series?.name">
<h6 style="margin-left:40px;" title="Localized Name">{{series?.localizedName}}</h6>
<h6 class="subtitle-with-actionables" title="Localized Name">{{series?.localizedName}}</h6>
</ng-container>
</app-side-nav-companion-bar>
<div class="container-fluid pt-2" *ngIf="series !== undefined">
@ -62,6 +62,26 @@
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"></app-series-metadata-detail>
</div>
<!-- <ng-container>
<div class="row g-0">
<div class="col-2">
<i class="fa-regular fa-file-lines" aria-hidden="true"></i>
{{series.pages}} Pages
</div>
|
<div class="col-2">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
1-2 Hours to Read
</div>
<ng-container *ngIf="utilityService.mangaFormat(series.format) === 'EPUB'">
|
<div class="col-2">
<i class="fa-regular fa-book-open" aria-hidden="true"></i>
10K Total Words
</div>
</ng-container>
</div>
</ng-container> -->
</div>
</div>

View file

@ -290,6 +290,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series], () => this.actionInProgress = false);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series, () => this.actionInProgress = false);
break;
default:
break;
}
@ -372,12 +375,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit)
.filter(action => this.actionFactoryService.filterBookmarksForFormat(action, this.series));
.filter(action => action.action !== Action.Edit);
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
// TODO: Move this to a forkJoin?
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
this.relations = [
...relations.prequels.map(item => this.createRelatedSeries(item, RelationKind.Prequel)),

View file

@ -3,22 +3,85 @@
</div>
<!-- This first row will have random information about the series-->
<div class="row g-0 mb-2">
<app-tag-badge title="Age Rating" *ngIf="seriesMetadata.ageRating" a11y-click="13,32" class="clickable col-auto" (click)="goTo(FilterQueryParam.AgeRating, seriesMetadata.ageRating)" [selectionMode]="TagBadgeCursor.Clickable">{{metadataService.getAgeRating(this.seriesMetadata.ageRating) | async}}</app-tag-badge>
<div class="row g-0 mb-4 mt-3">
<ng-container *ngIf="seriesMetadata.ageRating">
<div class="col-auto">
<app-icon-and-title [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 m-2"></div>
</ng-container>
<ng-container *ngIf="series">
<app-tag-badge *ngIf="seriesMetadata.releaseYear > 0" title="Release date" class="col-auto">{{seriesMetadata.releaseYear}}</app-tag-badge>
<app-tag-badge *ngIf="seriesMetadata.language !== null && seriesMetadata.language !== ''" title="Language" a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Languages, seriesMetadata.language)" [selectionMode]="TagBadgeCursor.Clickable">{{seriesMetadata.language}}</app-tag-badge>
<app-tag-badge title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})" [fillStyle]="seriesMetadata.maxCount != 0 && seriesMetadata.totalCount != 0 && seriesMetadata.maxCount >= seriesMetadata.totalCount ? 'filled' : 'outline'" a11y-click="13,32" class="col-auto"
(click)="goTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)"
[selectionMode]="TagBadgeCursor.Clickable">{{seriesMetadata.publicationStatus | publicationStatus}}</app-tag-badge>
<ng-container *ngIf="seriesMetadata.releaseYear > 0">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-calendar" title="Release Year">
{{seriesMetadata.releaseYear}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<app-tag-badge a11y-click="13,32" class="col-auto" (click)="goTo(FilterQueryParam.Format, series.format)" [selectionMode]="TagBadgeCursor.Clickable">
<app-series-format [format]="series.format">{{utilityService.mangaFormat(series.format)}}</app-series-format>
</app-tag-badge>
<app-tag-badge title="Last Read" class="col-auto" *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'" [selectionMode]="TagBadgeCursor.Selectable">
Last Read: {{series.latestReadDate | date:'shortDate'}}
</app-tag-badge>
<ng-container *ngIf="seriesMetadata.language !== null">
<div class="col-auto mb-2">
<app-icon-and-title [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 m-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="true" fontClasses="fa-solid fa-hourglass-empty" (click)="goTo(FilterQueryParam.PublicationStatus, seriesMetadata.publicationStatus)" title="Publication Status ({{seriesMetadata.maxCount}} / {{seriesMetadata.totalCount}})">
{{seriesMetadata.publicationStatus | publicationStatus}}
</app-icon-and-title>
</div>
<div class="vr m-2 mb-2"></div>
</ng-container>
<ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [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 m-2"></div>
</ng-container>
<ng-container *ngIf="series.latestReadDate && series.latestReadDate !== '' && (series.latestReadDate | date: 'shortDate') !== '1/1/01'">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-clock" title="Last Read">
{{series.latestReadDate | date:'shortDate'}}
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
</ng-container>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-file-lines">
{{series.pages}} Pages
</app-icon-and-title>
</div>
<div class="vr m-2"></div>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0 || series.format !== MangaFormat.EPUB">
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-regular fa-clock">
{{minHoursToRead}}{{maxHoursToRead !== minHoursToRead ? ('-' + maxHoursToRead) : ''}} Hours
</app-icon-and-title>
</div>
</ng-container>
<ng-container *ngIf="series.format === MangaFormat.EPUB && series.wordCount > 0">
<div class="vr m-2"></div>
<div class="col-auto mb-2">
<app-icon-and-title [clickable]="false" fontClasses="fa-solid fa-book-open">
{{series.wordCount | compactNumber}} Words
</app-icon-and-title>
</div>
</ng-container>
</ng-container>
</div>
@ -92,11 +155,6 @@
</div>
</div>
<div class="row g-0">
<hr class="col mt-3" *ngIf="hasExtendedProperites" >
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites" class="col col-md-auto align-self-end read-more-link" (click)="toggleView()">&nbsp;<i aria-hidden="true" class="fa fa-caret-{{isCollapsed ? 'down' : 'up'}}" aria-controls="extended-series-metadata"></i>&nbsp;See {{isCollapsed ? 'More' : 'Less'}}</a>
</div>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed" id="extended-series-metadata">
<div class="row g-0 mt-1" *ngIf="seriesMetadata.coverArtists && seriesMetadata.coverArtists.length > 0">
<div class="col-md-4">
@ -213,4 +271,13 @@
</app-badge-expander>
</div>
</div>
</div>
<div class="row g-0">
<hr class="col mt-3" *ngIf="hasExtendedProperites" >
<a [class.hidden]="hasExtendedProperites" *ngIf="hasExtendedProperites"
class="col col-md-auto align-self-end read-more-link" (click)="toggleView()">
<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>

View file

@ -9,6 +9,12 @@ import { Series } from '../../_models/series';
import { SeriesMetadata } from '../../_models/series-metadata';
import { MetadataService } from '../../_services/metadata.service';
const MAX_WORDS_PER_HOUR = 30_000;
const MIN_WORDS_PER_HOUR = 10_260;
const MAX_PAGES_PER_MINUTE = 2.75;
const MIN_PAGES_PER_MINUTE = 3.33;
@Component({
selector: 'app-series-metadata-detail',
templateUrl: './series-metadata-detail.component.html',
@ -26,6 +32,9 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
isCollapsed: boolean = true;
hasExtendedProperites: boolean = false;
minHoursToRead: number = 1;
maxHoursToRead: number = 1;
/**
* Html representation of Series Summary
*/
@ -58,8 +67,19 @@ 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.series.wordCount > 0) {
if (this.series.format === MangaFormat.EPUB) {
this.minHoursToRead = parseInt(Math.round(this.series.wordCount / MAX_WORDS_PER_HOUR) + '', 10);
this.maxHoursToRead = parseInt(Math.round(this.series.wordCount / MIN_WORDS_PER_HOUR) + '', 10);
} else if (this.series.format === MangaFormat.IMAGE || this.series.format === MangaFormat.ARCHIVE) {
this.minHoursToRead = parseInt(Math.round((this.series.wordCount * MAX_PAGES_PER_MINUTE) / 60) + '', 10);
this.maxHoursToRead = parseInt(Math.round((this.series.wordCount * MIN_PAGES_PER_MINUTE) / 60) + '', 10);
}
}
}
ngOnInit(): void {

View file

@ -0,0 +1,8 @@
<div class="d-flex justify-content-center align-self-center align-items-center icon-and-title" [ngClass]="{'clickable': clickable}" [attr.role]="clickable ? 'button' : ''"
(click)="handleClick($event)">
<i class="{{fontClasses}} mx-auto icon" aria-hidden="true" [title]="title"></i>
<div style="padding-top: 5px">
<ng-content></ng-content>
</div>
</div>

View file

@ -0,0 +1,9 @@
.icon-and-title {
flex-direction: column;
min-width: 60px;
}
.icon {
width: 20px;
height: 20px;
}

View file

@ -0,0 +1,32 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
@Component({
selector: 'app-icon-and-title',
templateUrl: './icon-and-title.component.html',
styleUrls: ['./icon-and-title.component.scss']
})
export class IconAndTitleComponent implements OnInit {
/**
* If the component is clickable and should emit click events
*/
@Input() clickable: boolean = true;
@Input() title: string = '';
/**
* Font classes used to display font
*/
@Input() fontClasses: string = '';
@Output() click: EventEmitter<MouseEvent> = new EventEmitter<MouseEvent>();
constructor() { }
ngOnInit(): void {
}
handleClick(event: MouseEvent) {
if (this.clickable) this.click.emit(event);
}
}

View file

@ -17,6 +17,7 @@ import { PersonBadgeComponent } from './person-badge/person-badge.component';
import { BadgeExpanderComponent } from './badge-expander/badge-expander.component';
import { ImageComponent } from './image/image.component';
import { PipeModule } from '../pipe/pipe.module';
import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component';
@NgModule({
declarations: [
@ -32,6 +33,7 @@ import { PipeModule } from '../pipe/pipe.module';
PersonBadgeComponent,
BadgeExpanderComponent,
ImageComponent,
IconAndTitleComponent,
],
imports: [
CommonModule,
@ -55,6 +57,8 @@ import { PipeModule } from '../pipe/pipe.module';
PersonBadgeComponent, // Used Series Detail
BadgeExpanderComponent, // Used Series Detail/Metadata
IconAndTitleComponent // Used in Series Detail/Metadata
],
})

View file

@ -84,6 +84,9 @@ export class SideNavComponent implements OnInit, OnDestroy {
case(Action.RefreshMetadata):
this.actionService.refreshMetadata(library);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFiles(library);
break;
default:
break;
}

View file

@ -25,3 +25,7 @@ hr {
.text-muted {
color: var(--text-muted-color) !important;
}
.subtitle-with-actionables {
margin-left: 32px;
}