Linked Series (#1230)

* Implemented the ability to link different series together through Edit Series. CSS pending.

* Fixed up the css for related cards to show the relation

* Working on making all tabs in edit seris modal save in one go. Taking a break.

* Some fixes for Robbie to help with styling on

* Linked series pill, center library

* Centering library detail and related pill spacing

- Library detail cards are now centered if total number of items is > 6 or if mobile.
- Added ability to determine if mobile (viewport width <= 480px
- Fixed related card spacing
- Fixed related card pill spacing

* Updating relation form spacing

* Fixed a bug in card detail layout when there is no pagination, we create one in a way that all items render at once.

* Only auto-close side nav on phones, not tablets

* Fixed a bug where we had flipped state on sideNavCollapsed$

* Cleaned up some misleading comments

* Implemented RBS back in and now  if you have a relationship besides prequel/sequel, the target series will show a link back to it's parent.

* Added Parentto pipe

* Missed a relationship type

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joseph Milazzo 2022-04-24 11:59:09 -05:00 committed by GitHub
parent 7253765f1d
commit 4206ae3e22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 2571 additions and 195 deletions

View file

@ -0,0 +1,17 @@
import { Series } from "../series";
export interface RelatedSeries {
sourceSeriesId: number;
sequels: Array<Series>;
prequels: Array<Series>;
spinOffs: Array<Series>;
adaptations: Array<Series>;
sideStories: Array<Series>;
characters: Array<Series>;
contains: Array<Series>;
others: Array<Series>;
alternativeSettings: Array<Series>;
alternativeVersions: Array<Series>;
doujinshis: Array<Series>;
parent: Array<Series>;
}

View file

@ -0,0 +1,31 @@
export enum RelationKind {
Prequel = 1,
Sequel = 2,
SpinOff = 3,
Adaptation = 4,
SideStory = 5,
Character = 6,
Contains = 7,
Other = 8,
AlternativeSetting = 9,
AlternativeVersion = 10,
Doujinshi = 11,
/**
* This is UI only. Backend will generate Parent series for everything but Prequel/Sequel
*/
Parent = 12
}
export const RelationKinds = [
{text: 'Prequel', value: RelationKind.Prequel},
{text: 'Sequel', value: RelationKind.Sequel},
{text: 'Spin Off', value: RelationKind.SpinOff},
{text: 'Adaptation', value: RelationKind.Adaptation},
{text: 'Alternative Setting', value: RelationKind.AlternativeSetting},
{text: 'Alternative Version', value: RelationKind.AlternativeVersion},
{text: 'Side Story', value: RelationKind.SideStory},
{text: 'Character', value: RelationKind.Character},
{text: 'Contains', value: RelationKind.Contains},
{text: 'Doujinshi', value: RelationKind.Doujinshi},
{text: 'Other', value: RelationKind.Other},
];

View file

@ -4,7 +4,6 @@ import { of } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Library, LibraryType } from '../_models/library';
import { SearchResult } from '../_models/search-result';
import { SearchResultGroup } from '../_models/search/search-result-group';

View file

@ -9,6 +9,8 @@ import { CollectionTag } from '../_models/collection-tag';
import { PaginatedResult } from '../_models/pagination';
import { RecentlyAddedItem } from '../_models/recently-added-item';
import { Series } from '../_models/series';
import { RelatedSeries } from '../_models/series-detail/related-series';
import { RelationKind } from '../_models/series-detail/relation-kind';
import { SeriesDetail } from '../_models/series-detail/series-detail';
import { SeriesFilter } from '../_models/series-filter';
import { SeriesGroup } from '../_models/series-group';
@ -182,6 +184,19 @@ export class SeriesService {
);
}
getRelatedForSeries(seriesId: number) {
return this.httpClient.get<RelatedSeries>(this.baseUrl + 'series/all-related?seriesId=' + seriesId);
}
updateRelationships(seriesId: number, adaptations: Array<number>, characters: Array<number>,
contains: Array<number>, others: Array<number>, prequels: Array<number>,
sequels: Array<number>, sideStories: Array<number>, spinOffs: Array<number>,
alternativeSettings: Array<number>, alternativeVersions: Array<number>, doujinshis: Array<number>) {
return this.httpClient.post(this.baseUrl + 'series/update-related?seriesId=' + seriesId,
{seriesId, adaptations, characters, sequels, prequels, contains, others, sideStories, spinOffs,
alternativeSettings, alternativeVersions, doujinshis});
}
getSeriesDetail(seriesId: number) {
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
}

View file

@ -1,10 +1,10 @@
<app-nav-header></app-nav-header>
<div [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
<div [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
<a id="content"></a>
<app-side-nav *ngIf="navService.sideNavVisibility$ | async"></app-side-nav>
<div class="container-fluid">
<div style="padding-top: 10px; padding-bottom: 65px;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
<div class="companion-bar" [ngClass]="{'companion-bar-content': (navService?.sideNavCollapsed$ | async)}">
<div class="companion-bar" [ngClass]="{'companion-bar-content': !(navService?.sideNavCollapsed$ | async)}">
<router-outlet></router-outlet>
</div>
</div>

View file

@ -330,7 +330,13 @@
</ng-template>
</li>
<li [ngbNavItem]="tabs[4]">
<a ngbNavLink>{{tabs[4]}}</a>
<a ngbNavLink>{{tabs[4]}}</a>
<ng-template ngbNavContent>
<app-edit-series-relation [series]="series" [save]="saveNestedComponents"></app-edit-series-relation>
</ng-template>
</li>
<li [ngbNavItem]="tabs[5]">
<a ngbNavLink>{{tabs[5]}}</a>
<ng-template ngbNavContent>
<h4>Information</h4>
<div class="row g-0 mb-2">

View file

@ -1,4 +1,4 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { forkJoin, Observable, of, Subject } from 'rxjs';
@ -39,7 +39,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
isCollapsed = true;
volumeCollapsed: any = {};
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Info'];
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Related', 'Info'];
active = this.tabs[0];
editSeriesForm!: FormGroup;
libraryName: string | undefined = undefined;
@ -73,6 +73,8 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
coverImageReset = false;
saveNestedComponents: EventEmitter<void> = new EventEmitter();
get Breakpoint(): typeof Breakpoint {
return Breakpoint;
}
@ -420,6 +422,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
apis.push(this.uploadService.updateSeriesCoverImage(model.id, this.selectedCover));
}
this.saveNestedComponents.emit();
forkJoin(apis).subscribe(results => {
this.modal.close({success: true, series: model, coverImageUpdate: selectedIndex > 0});

View file

@ -1,24 +1,24 @@
<div class="row mt-2 g-0 pb-2" *ngIf="header !== undefined && header.length > 0">
<div class="col me-auto">
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span>
<span *ngIf="header !== undefined && header.length > 0">
{{header}}&nbsp;
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</span>
</h2>
</div>
<div class="col me-auto">
<h2 style="display: inline-block">
<span *ngIf="actions.length > 0" class="">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span>
<span *ngIf="header !== undefined && header.length > 0">
{{header}}&nbsp;
<span class="badge bg-primary rounded-pill" attr.aria-label="{{pagination.totalItems}} total items" *ngIf="pagination != undefined">{{pagination.totalItems}}</span>
</span>
</h2>
</div>
</div>
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
<app-metadata-filter [filterSettings]="filterSettings" [filterOpen]="filterOpen" (applyFilter)="applyMetadataFilter($event)"></app-metadata-filter>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'top' }"></ng-container>
<div class="row g-0 mt-2 mb-2">
<ng-container *ngIf="pagination.totalItems > 6 || isMobile; else cardTemplate">
<div class="d-flex justify-content-center row g-0 mt-2 mb-2">
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
@ -27,50 +27,63 @@
<ng-container [ngTemplateOutlet]="noDataTemplate"></ng-container>
</p>
</div>
</ng-container>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
<ng-container [ngTemplateOutlet]="paginationTemplate" [ngTemplateOutletContext]="{ id: 'bottom' }"></ng-container>
<ng-template #cardTemplate>
<div class="row g-0 mt-2 mb-2">
<div class="col-auto ps-1 pe-1 mt-2 mb-2" *ngFor="let item of items; trackBy:trackByIdentity; index as i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
<p *ngIf="items.length === 0 && !isLoading">
There is no data
</p>
</div>
</ng-template>
<ng-template #paginationTemplate let-id="id">
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[maxSize]="8"
[rotate]="true"
[ellipses]="false"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[collectionSize]="pagination.totalItems">
<div class="d-flex justify-content-center mb-0" *ngIf="pagination && items.length > 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[maxSize]="8"
[rotate]="true"
[ellipses]="false"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[collectionSize]="pagination.totalItems">
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
<div class="d-flex flex-nowrap px-2">
<label
id="paginationInputLabel-{{id}}"
for="paginationInput-{{id}}"
class="col-form-label me-2 ms-1 form-label"
>Page</label>
<input #i
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="form-control custom-pages-input"
id="paginationInput-{{id}}"
[value]="page"
(keyup.enter)="selectPageStr(i.value)"
(blur)="selectPageStr(i.value)"
(input)="formatInput($any($event).target)"
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
/>
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
of {{pagination.totalPages}}</span>
</div>
</li>
</ng-template>
<ng-template ngbPaginationPages let-page let-pages="pages" *ngIf="pagination.totalItems / pagination.itemsPerPage > 20">
<li class="ngb-custom-pages-item" *ngIf="pagination.totalPages > 1">
<div class="d-flex flex-nowrap px-2">
<label
id="paginationInputLabel-{{id}}"
for="paginationInput-{{id}}"
class="col-form-label me-2 ms-1 form-label"
>Page</label>
<input #i
type="text"
inputmode="numeric"
pattern="[0-9]*"
class="form-control custom-pages-input"
id="paginationInput-{{id}}"
[value]="page"
(keyup.enter)="selectPageStr(i.value)"
(blur)="selectPageStr(i.value)"
(input)="formatInput($any($event).target)"
attr.aria-labelledby="paginationInputLabel-{{id}} paginationDescription-{{id}}"
[ngStyle]="{width: (0.5 + pagination.currentPage + '').length + 'rem'} "
/>
<span id="paginationDescription-{{id}}" class="col-form-label text-nowrap px-2">
of {{pagination.totalPages}}</span>
</div>
</li>
</ng-template>
</ngb-pagination>
</div>
</ngb-pagination>
</div>
</ng-template>
<div class="mx-auto" *ngIf="isLoading" style="width: 200px;">

View file

@ -52,6 +52,7 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
private onDestory: Subject<void> = new Subject();
isMobile: boolean = false;
constructor(private seriesService: SeriesService) {
this.filter = this.seriesService.createSeriesFilter();
@ -62,9 +63,15 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy {
if (this.filterSettings === undefined) {
console.log('filter settings was empty, creating our own');
this.filterSettings = new FilterSettings();
}
if (this.pagination === undefined) {
this.pagination = {currentPage: 1, itemsPerPage: this.items.length, totalItems: this.items.length, totalPages: 1}
}
this.isMobile = window.innerWidth <= 480;
window.onresize = () => this.isMobile = window.innerWidth <= 480;
}
ngOnDestroy() {

View file

@ -30,6 +30,11 @@
<span class="badge bg-primary">{{count}}</span>
</div>
<div class="card-overlay"></div>
<div class="overlay-information" *ngIf="overlayInformation !== '' || overlayInformation !== undefined">
<div class="position-relative">
<span class="card-title library mx-auto" style="width: auto;" [ngbTooltip]="overlayInformation" placement="top">{{overlayInformation}}</span>
</div>
</div>
</div>
<div class="card-body" *ngIf="title.length > 0 || actions.length > 0">

View file

@ -100,6 +100,14 @@ $image-width: 160px;
}
}
.overlay-information {
position: absolute;
top: 5px;
left: 5px;
border-radius: 15px;
padding: 0 10px;
background-color: var(--card-bg-color);
}
.overlay {
height: $image-height;

View file

@ -71,11 +71,15 @@ export class CardItemComponent implements OnInit, OnDestroy {
/**
* This will supress the cannot read archive warning when total pages is 0
*/
@Input() supressArchiveWarning: boolean = false;
@Input() supressArchiveWarning: boolean = false;
/**
* The number of updates/items within the card. If less than 2, will not be shown.
*/
@Input() count: number = 0;
@Input() count: number = 0;
/**
* Additional information to show on the overlay area. Will always render.
*/
@Input() overlayInformation: string = '';
/**
* Event emitted when item is clicked
*/

View file

@ -21,6 +21,7 @@ import { PipeModule } from '../pipe/pipe.module';
import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component';
import { FileInfoComponent } from './file-info/file-info.component';
import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module';
import { EditSeriesRelationComponent } from './edit-series-relation/edit-series-relation.component';
@ -39,19 +40,20 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
BulkAddToCollectionComponent,
ChapterMetadataDetailComponent,
FileInfoComponent,
EditSeriesRelationComponent,
],
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
FormsModule, // EditCollectionsModal
PipeModule,
SharedModule,
TypeaheadModule, // edit series modal
MetadataFilterModule,
NgbNavModule,
NgbTooltipModule, // Card item
NgbCollapseModule,
@ -79,7 +81,8 @@ import { MetadataFilterModule } from '../metadata-filter/metadata-filter.module'
CardDetailLayoutComponent,
CardDetailsModalComponent,
BulkOperationsComponent,
ChapterMetadataDetailComponent
ChapterMetadataDetailComponent,
EditSeriesRelationComponent
]
})
export class CardsModule { }

View file

@ -0,0 +1,36 @@
<div class="container-fluid">
<p>
Not sure what relationship to add? See our <a href="https://wiki.kavitareader.com/en/guides/get-started-using-your-library/series-relationships" target="_blank" referrerpolicy="no-refer">wiki for hints</a>.
</p>
<div class="row g-0" *ngIf="relations.length > 0">
<label class="form-label col-md-7">Target Series</label>
<label class="form-label col-md-5">Relationship</label>
</div>
<form>
<div class="row g-0 mb-3" *ngFor="let relation of relations; let idx = index; let isLast = last;">
<div class="col-sm-12 col-md-7">
<app-typeahead (selectedData)="updateSeries($event, relation)" [settings]="relation.typeaheadSettings">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}} ({{libraryNames[item.libraryId]}})
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}} ({{libraryNames[item.libraryId]}})
</ng-template>
</app-typeahead>
</div>
<div class="col-sm-auto col-md-3">
<select class="form-select" [formControl]="relation.formControl">
<option *ngFor="let opt of relationOptions" [value]="opt.value">{{opt.text}}</option>
</select>
</div>
<button class="col-sm-auto col-md-2 btn btn-outline-secondary" (click)="removeRelation(idx)">Remove</button>
</div>
</form>
<div class="row g-0 mt-3 mb-3">
<button class="btn btn-outline-secondary col-md-12" (click)="addNewRelation()">Add Relationship</button>
</div>
</div>

View file

@ -0,0 +1,150 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import { map, Subject, Observable, of, firstValueFrom, takeUntil, ReplaySubject } from 'rxjs';
import { UtilityService } from 'src/app/shared/_services/utility.service';
import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings';
import { SearchResult } from 'src/app/_models/search-result';
import { Series } from 'src/app/_models/series';
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SeriesService } from 'src/app/_services/series.service';
interface RelationControl {
series: {id: number, name: string} | undefined; // Will add type as well
typeaheadSettings: TypeaheadSettings<SearchResult>;
formControl: FormControl;
}
@Component({
selector: 'app-edit-series-relation',
templateUrl: './edit-series-relation.component.html',
styleUrls: ['./edit-series-relation.component.scss']
})
export class EditSeriesRelationComponent implements OnInit, OnDestroy {
@Input() series!: Series;
/**
* This will tell the component to save based on it's internal state
*/
@Input() save: EventEmitter<void> = new EventEmitter();
@Output() saveApi = new ReplaySubject(1);
relationOptions = RelationKinds;
relations: Array<RelationControl> = [];
seriesSettings: TypeaheadSettings<SearchResult> = new TypeaheadSettings();
libraryNames: {[key:number]: string} = {};
private onDestroy: Subject<void> = new Subject<void>();
constructor(private seriesService: SeriesService, private utilityService: UtilityService,
public imageService: ImageService, private libraryService: LibraryService) { }
ngOnInit(): void {
this.seriesService.getRelatedForSeries(this.series.id).subscribe(async relations => {
this.setupRelationRows(relations.prequels, RelationKind.Prequel);
this.setupRelationRows(relations.sequels, RelationKind.Sequel);
this.setupRelationRows(relations.sideStories, RelationKind.SideStory);
this.setupRelationRows(relations.spinOffs, RelationKind.SpinOff);
this.setupRelationRows(relations.adaptations, RelationKind.Adaptation);
this.setupRelationRows(relations.others, RelationKind.Other);
this.setupRelationRows(relations.characters, RelationKind.Character);
this.setupRelationRows(relations.alternativeSettings, RelationKind.AlternativeSetting);
this.setupRelationRows(relations.alternativeVersions, RelationKind.AlternativeVersion);
this.setupRelationRows(relations.doujinshis, RelationKind.Doujinshi);
});
this.libraryService.getLibraryNames().subscribe(names => {
this.libraryNames = names;
});
this.save.pipe(takeUntil(this.onDestroy)).subscribe(() => this.saveState());
}
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
setupRelationRows(relations: Array<Series>, kind: RelationKind) {
relations.map(async item => {
const settings = await firstValueFrom(this.createSeriesTypeahead(item, kind));
return {series: item, typeaheadSettings: settings, formControl: new FormControl(kind, [])}
}).forEach(async p => {
this.relations.push(await p);
});
}
async addNewRelation() {
this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation))});
}
removeRelation(index: number) {
this.relations.splice(index, 1);
}
updateSeries(event: Array<SearchResult | undefined>, relation: RelationControl) {
if (event[0] === undefined) {
relation.series = undefined;
return;
}
relation.series = {id: event[0].seriesId, name: event[0].name};
}
createSeriesTypeahead(series: Series | undefined, relationship: RelationKind): Observable<TypeaheadSettings<SearchResult>> {
const seriesSettings = new TypeaheadSettings<SearchResult>();
seriesSettings.minCharacters = 0;
seriesSettings.multiple = false;
seriesSettings.id = 'format';
seriesSettings.unique = true;
seriesSettings.addIfNonExisting = false;
seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe(
map(group => group.series),
map(items => seriesSettings.compareFn(items, searchFilter)),
map(series => series.filter(s => s.seriesId !== this.series.id)),
);
seriesSettings.compareFn = (options: SearchResult[], filter: string) => {
return options.filter(m => this.utilityService.filter(m.name, filter));
}
seriesSettings.selectionCompareFn = (a: SearchResult, b: SearchResult) => {
return a.seriesId == b.seriesId;
}
if (series !== undefined) {
return this.libraryService.search(series.name).pipe(
map(group => group.series), map(results => {
seriesSettings.savedData = results.filter(s => s.seriesId === series.id);
return seriesSettings;
}));
}
return of(seriesSettings);
}
saveState() {
const adaptations = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Adaptation && item.series !== undefined).map(item => item.series!.id);
const characters = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Character && item.series !== undefined).map(item => item.series!.id);
const contains = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Contains && item.series !== undefined).map(item => item.series!.id);
const others = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Other && item.series !== undefined).map(item => item.series!.id);
const prequels = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Prequel && item.series !== undefined).map(item => item.series!.id);
const sequels = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Sequel && item.series !== undefined).map(item => item.series!.id);
const sideStories = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.SideStory && item.series !== undefined).map(item => item.series!.id);
const spinOffs = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.SpinOff && item.series !== undefined).map(item => item.series!.id);
const alternativeSettings = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeSetting && item.series !== undefined).map(item => item.series!.id);
const alternativeVersions = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeVersion && item.series !== undefined).map(item => item.series!.id);
const doujinshis = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Doujinshi && item.series !== undefined).map(item => item.series!.id);
// TODO: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin
//this.saveApi.next(this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis));
this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis).subscribe(() => {});
}
}

View file

@ -2,5 +2,6 @@
<app-card-item [title]="data.name" [actions]="actions" [suppressLibraryLink]="suppressLibraryLink" [imageUrl]="imageUrl"
[entity]="data" [total]="data.pages" [read]="data.pagesRead" (clicked)="handleClick()"
[allowSelection]="allowSelection" (selection)="selection.emit(selected)" [selected]="selected"
[overlayInformation]="(relation | relationship)"
></app-card-item>
</ng-container>

View file

@ -13,6 +13,7 @@ import { ActionService } from 'src/app/_services/action.service';
import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component';
import { MessageHubService } from 'src/app/_services/message-hub.service';
import { Subject } from 'rxjs';
import { RelationKind } from 'src/app/_models/series-detail/relation-kind';
@Component({
selector: 'app-series-card',
@ -26,11 +27,15 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy {
/**
* If the entity is selected or not.
*/
@Input() selected: boolean = false;
/**
* If the entity should show selection code
*/
@Input() allowSelection: boolean = false;
@Input() selected: boolean = false;
/**
* If the entity should show selection code
*/
@Input() allowSelection: boolean = false;
/**
* If the Series has a relationship to display
*/
@Input() relation: RelationKind | undefined = undefined;
@Output() clicked = new EventEmitter<Series>();
@Output() reload = new EventEmitter<boolean>();

View file

@ -9,97 +9,97 @@
<label for="nav-search" class="form-label visually-hidden">Search series</label>
<div class="ng-autocomplete">
<app-grouped-typeahead
#search
id="nav-search"
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
#search
id="nav-search"
[minQueryLength]="2"
initialValue=""
placeholder="Search…"
[grouppedData]="searchResults"
(inputChanged)="onChangeSearch($event)"
(clearField)="clearSearch()"
(focusChanged)="focusUpdate($event)"
>
<ng-template #libraryTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
<div class="ms-1">
<span>{{item.name}}</span>
<ng-template #libraryTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickLibraryResult(item)">
<div class="ms-1">
<span>{{item.name}}</span>
</div>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #seriesTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
<ng-template #seriesTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickSeriesSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"></app-image>
</div>
<div class="ms-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
<div class="ms-1">
<app-series-format [format]="item.format"></app-series-format>
<span *ngIf="item.name.toLowerCase().trim().indexOf(searchTerm) >= 0; else localizedName">{{item.name}}</span>
<ng-template #localizedName>
<span [innerHTML]="item.localizedName"></span>
</ng-template>
<div class="form-text" style="font-size: 0.8rem;">in {{item.libraryName}}</div>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #collectionTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
<ng-template #collectionTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickCollectionSearchResult(item)">
<div style="width: 24px" class="me-1">
<app-image class="me-3 search-result" width="24px" [imageUrl]="imageService.getCollectionCoverImage(item.id)"></app-image>
</div>
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #readingListTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
<ng-template #readingListTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="clickReadingListSearchResult(item)">
<div class="ms-1">
<span>{{item.title}}</span>
<span *ngIf="item.promoted">
&nbsp;<i class="fa fa-angle-double-up" aria-hidden="true" title="Promoted"></i>
<span class="visually-hidden">(promoted)</span>
</span>
</div>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
<div class="ms-1">
<span>{{item.title}}</span>
<ng-template #tagTemplate let-item>
<div style="display: flex;padding: 5px;" (click)="goTo('tags', item.id)">
<div class="ms-1">
<span>{{item.title}}</span>
</div>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ms-1">
<div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div>
<ng-template #personTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goToPerson(item.role, item.id)">
<div class="ms-1">
<div [innerHTML]="item.name"></div>
<div>{{item.role | personRole}}</div>
</div>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div class="ms-1">
<div [innerHTML]="item.title"></div>
<ng-template #genreTemplate let-item>
<div style="display: flex;padding: 5px;" class="clickable" (click)="goTo('genres', item.id)">
<div class="ms-1">
<div [innerHTML]="item.title"></div>
</div>
</div>
</div>
</ng-template>
</ng-template>
<ng-template #noResultsTemplate let-notFound>
No results found
</ng-template>
<ng-template #noResultsTemplate let-notFound>
No results found
</ng-template>
</app-grouped-typeahead>
</div>

View file

@ -1,10 +1,10 @@
import { DOCUMENT } from '@angular/common';
import { Component, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { from, fromEvent, Subject } from 'rxjs';
import { fromEvent, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ScrollService } from '../scroll.service';
import { FilterQueryParam, FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { FilterQueryParam } from '../shared/_services/filter-utilities.service';
import { CollectionTag } from '../_models/collection-tag';
import { Library } from '../_models/library';
import { PersonRole } from '../_models/person';
@ -46,19 +46,20 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
searchFocused: boolean = false;
private readonly onDestroy = new Subject<void>();
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private filterUtilityService: FilterUtilitiesService) { }
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService) { }
ngOnInit(): void {
fromEvent(this.document.body, 'scroll').pipe(takeUntil(this.onDestroy)).subscribe(() => {
const offset = this.scrollService.scrollPosition;
if (offset > 100) {
this.backToTopNeeded = true;
} else if (offset < 40) {
this.backToTopNeeded = false;
}
})
ngOnInit(): void {}
@HostListener('body:scroll', [])
checkBackToTopNeeded() {
const offset = this.scrollService.scrollPosition;
if (offset > 100) {
this.backToTopNeeded = true;
} else if (offset < 40) {
this.backToTopNeeded = false;
}
}
ngOnDestroy() {
@ -77,7 +78,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
this.document.getElementById('content')?.focus();
}
onChangeSearch(val: string) {
this.isLoading = true;

View file

@ -5,6 +5,7 @@ import { PublicationStatusPipe } from './publication-status.pipe';
import { SentenceCasePipe } from './sentence-case.pipe';
import { PersonRolePipe } from './person-role.pipe';
import { SafeHtmlPipe } from './safe-html.pipe';
import { RelationshipPipe } from './relationship.pipe';
@ -14,7 +15,8 @@ import { SafeHtmlPipe } from './safe-html.pipe';
PersonRolePipe,
PublicationStatusPipe,
SentenceCasePipe,
SafeHtmlPipe
SafeHtmlPipe,
RelationshipPipe
],
imports: [
CommonModule,
@ -24,7 +26,8 @@ import { SafeHtmlPipe } from './safe-html.pipe';
PersonRolePipe,
PublicationStatusPipe,
SentenceCasePipe,
SafeHtmlPipe
SafeHtmlPipe,
RelationshipPipe
]
})
export class PipeModule { }

View file

@ -0,0 +1,8 @@
import { RelationshipPipe } from './relationship.pipe';
describe('RelationshipPipe', () => {
it('create an instance', () => {
const pipe = new RelationshipPipe();
expect(pipe).toBeTruthy();
});
});

View file

@ -0,0 +1,41 @@
import { Pipe, PipeTransform } from '@angular/core';
import { RelationKind } from '../_models/series-detail/relation-kind';
@Pipe({
name: 'relationship'
})
export class RelationshipPipe implements PipeTransform {
transform(relationship: RelationKind | undefined): string {
if (relationship === undefined) return '';
switch (relationship) {
case RelationKind.Adaptation:
return 'Adaptaion';
case RelationKind.AlternativeSetting:
return 'Alternative Setting';
case RelationKind.AlternativeVersion:
return 'Alternative Version';
case RelationKind.Character:
return 'Character';
case RelationKind.Contains:
return 'Contains';
case RelationKind.Doujinshi:
return 'Doujinshi';
case RelationKind.Other:
return 'Other';
case RelationKind.Prequel:
return 'Prequel';
case RelationKind.Sequel:
return 'Sequel';
case RelationKind.SideStory:
return 'Side Story';
case RelationKind.SpinOff:
return 'Spin Off';
case RelationKind.Parent:
return 'Parent';
default:
return '';
}
}
}

View file

@ -69,6 +69,17 @@
<ng-container>
<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.Related" *ngIf="hasRelations">
<a ngbNavLink>Related</a>
<ng-template ngbNavContent>
<div class="row g-0">
<ng-container *ngFor="let item of relations; let idx = index; trackBy: trackByRelatedSeriesIdentiy">
<app-series-card class="col-auto p-2" [data]="item.series" [libraryId]="item.series.libraryId" [relation]="item.relation"></app-series-card>
<!--(reload)="reloadInProgress($event)" (dataChanged)="reloadInProgress($event)"-->
</ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Specials" *ngIf="hasSpecials">
<a ngbNavLink>Specials</a>
<ng-template ngbNavContent>

View file

@ -33,9 +33,16 @@ import { ReaderService } from '../_services/reader.service';
import { ReadingListService } from '../_services/reading-list.service';
import { SeriesService } from '../_services/series.service';
import { NavService } from '../_services/nav.service';
import { RelationKind } from '../_models/series-detail/relation-kind';
import { RelatedSeries } from '../_models/series-detail/related-series';
interface RelatedSeris {
series: Series;
relation: RelationKind;
}
enum TabID {
Related = 0,
Specials = 1,
Storyline = 2,
Volumes = 3,
@ -106,6 +113,16 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
* Track by function for Chapter to tell when to refresh card data
*/
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}`;
/**
* Are there any related series
*/
hasRelations: boolean = false;
/**
* Related Series. Sorted by backend
*/
relations: Array<RelatedSeris> = [];
bulkActionCallback = (action: Action, data: any) => {
if (this.series === undefined) {
@ -356,6 +373,27 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
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)),
...relations.sequels.map(item => this.createRelatedSeries(item, RelationKind.Sequel)),
...relations.sideStories.map(item => this.createRelatedSeries(item, RelationKind.SideStory)),
...relations.spinOffs.map(item => this.createRelatedSeries(item, RelationKind.SpinOff)),
...relations.adaptations.map(item => this.createRelatedSeries(item, RelationKind.Adaptation)),
...relations.contains.map(item => this.createRelatedSeries(item, RelationKind.Contains)),
...relations.characters.map(item => this.createRelatedSeries(item, RelationKind.Character)),
...relations.others.map(item => this.createRelatedSeries(item, RelationKind.Other)),
...relations.alternativeSettings.map(item => this.createRelatedSeries(item, RelationKind.AlternativeSetting)),
...relations.alternativeVersions.map(item => this.createRelatedSeries(item, RelationKind.AlternativeVersion)),
...relations.doujinshis.map(item => this.createRelatedSeries(item, RelationKind.Doujinshi)),
...relations.parent.map(item => this.createRelatedSeries(item, RelationKind.Parent)),
];
if (this.relations.length > 0) {
this.hasRelations = true;
}
});
this.seriesService.getSeriesDetail(this.seriesId).subscribe(detail => {
this.hasSpecials = detail.specials.length > 0;
this.specials = detail.specials;
@ -372,6 +410,10 @@ export class SeriesDetailComponent implements OnInit, OnDestroy {
});
}
createRelatedSeries(series: Series, relation: RelationKind) {
return {series, relation} as RelatedSeris;
}
/**
* This will update the selected tab
*

View file

@ -47,7 +47,7 @@ export class SideNavCompanionBarComponent implements OnInit, OnDestroy {
// If user opens side nav while filter is open on mobile, then collapse filter (as it doesn't render well) TODO: Change this when we have new drawer
this.navService.sideNavCollapsed$.pipe(takeUntil(this.onDestroy)).subscribe(sideNavCollapsed => {
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop) {
if (this.isFilterOpen && sideNavCollapsed && this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {
this.isFilterOpen = false;
this.filterOpen.emit(this.isFilterOpen);
}

View file

@ -1,11 +1,11 @@
<ng-container *ngIf="link === undefined || link.length === 0; else useLink">
<div class="side-nav-item" [ngClass]="{'closed': !(navService?.sideNavCollapsed$ | async), 'active': highlighted}">
<div class="side-nav-item" [ngClass]="{'closed': (navService?.sideNavCollapsed$ | async), 'active': highlighted}">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</div>
</ng-container>
<ng-template #useLink>
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': !(navService?.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
<a class="side-nav-item" href="javascript:void(0);" [ngClass]="{'closed': (navService?.sideNavCollapsed$ | async), 'active': highlighted}" [routerLink]="link">
<ng-container [ngTemplateOutlet]="inner"></ng-container>
</a>
</ng-template>

View file

@ -1,5 +1,5 @@
<ng-container>
<div class="side-nav" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async), 'hidden' :!(navService?.sideNavVisibility$ | async)}" *ngIf="accountService.currentUser$ | async as user">
<div class="side-nav" [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async), 'hidden' :!(navService?.sideNavVisibility$ | async)}" *ngIf="accountService.currentUser$ | async as user">
<!-- <app-side-nav-item icon="fa-user-circle align-self-center phone-hidden" [title]="user.username | sentenceCase" link="/preferences/">
<ng-container actions>
Todo: This will be customize dashboard/side nav controls
@ -26,5 +26,5 @@
</ng-container>
</app-side-nav-item>
</div>
<div class="side-nav-overlay" (click)="navService?.toggleSideNav()" [ngClass]="{'closed' : !(navService?.sideNavCollapsed$ | async)}"></div>
<div class="side-nav-overlay" (click)="navService?.toggleSideNav()" [ngClass]="{'closed' : (navService?.sideNavCollapsed$ | async)}"></div>
</ng-container>

View file

@ -60,9 +60,14 @@ export class SideNavComponent implements OnInit, OnDestroy {
takeUntil(this.onDestroy),
map(evt => evt as NavigationEnd))
.subscribe((evt: NavigationEnd) => {
if (this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop) {
if (this.utilityService.getActiveBreakpoint() < Breakpoint.Tablet) {
// collapse side nav
this.navService.toggleSideNav();
this.navService.sideNavCollapsed$.pipe(take(1)).subscribe(collapsed => {
console.log('Side nav collapsed: ', collapsed);
if (!collapsed) {
this.navService.toggleSideNav();
}
});
}
});
}