(Kavita+) External Series Detail (#2309)

This commit is contained in:
Joe Milazzo 2023-10-11 19:31:40 -05:00 committed by GitHub
parent bd62e00ec5
commit 6067c9233c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 2354 additions and 726 deletions

View file

@ -110,8 +110,6 @@ export class ErrorInterceptor implements HttpInterceptor {
if (error.message !== 'User is not authenticated' && error.message !== 'errors.user-not-auth') {
console.error('500 error: ', error);
}
// This just throws duplicate errors for no reason
//this.toast(error.message);
}
else {
this.toast('errors.unknown-crit');
@ -120,7 +118,6 @@ export class ErrorInterceptor implements HttpInterceptor {
}
private handleAuthError(error: any) {
// Special hack for register url, to not care about auth
if (location.href.includes('/registration/confirm-email?token=')) {
return;

View file

@ -0,0 +1,41 @@
export enum PlusMediaFormat {
Manga = 1,
Comic = 2,
LightNovel = 3,
Book = 4
}
export interface SeriesStaff {
name: string;
url: string;
role: string;
imageUrl?: string;
gender?: string;
description?: string;
}
export interface MetadataTagDto {
name: string;
description: string;
rank?: number;
isGeneralSpoiler: boolean;
isMediaSpoiler: boolean;
isAdult: boolean;
}
export interface ExternalSeriesDetail {
name: string;
aniListId?: number;
malId?: number;
synonyms: Array<string>;
plusMediaFormat: PlusMediaFormat;
siteUrl?: string;
coverUrl?: string;
genres: Array<string>;
summary?: string;
volumeCount?: number;
chapterCount?: number;
staff: Array<SeriesStaff>;
tags: Array<MetadataTagDto>;
}

View file

@ -3,4 +3,6 @@ export interface ExternalSeries {
coverUrl: string;
url: string;
summary: string;
aniListId?: number;
malId?: number;
}

View file

@ -3,7 +3,6 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
import { UtilityService } from '../shared/_services/utility.service';
import { Chapter } from '../_models/chapter';
import { ChapterMetadata } from '../_models/metadata/chapter-metadata';
@ -21,6 +20,7 @@ import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating";
import {Recommendation} from "../_models/series-detail/recommendation";
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
@Injectable({
providedIn: 'root'
@ -228,4 +228,8 @@ export class SeriesService {
removeFromOnDeck(seriesId: number) {
return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {});
}
getExternalSeriesDetails(aniListId?: number, malId?: number) {
return this.httpClient.get<ExternalSeriesDetail>(this.baseUrl + 'series/external-series-detail?aniListId=' + (aniListId || 0) + '&malId=' + (malId || 0));
}
}

View file

@ -0,0 +1,119 @@
<ng-container *transloco="let t">
<div class="offcanvas-header">
<h5 class="offcanvas-title">
<ng-container *ngIf="CoverUrl as coverUrl">
<app-image *ngIf="coverUrl" height="230px" width="160px" maxHeight="230px" objectFit="contain" [imageUrl]="coverUrl"></app-image>
<div class="">
{{name}}
</div>
</ng-container>
</h5>
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('common.close')" (click)="close()"></button>
</div>
<div class="offcanvas-body">
<ng-container *ngIf="externalSeries; else localSeriesBody">
<span *ngIf="(externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0" class="text-muted" style="font-size: 14px; color: lightgrey">{{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}}</span>
<app-read-more *ngIf="externalSeries.summary" [maxLength]="300" [text]="externalSeries.summary"></app-read-more>
<div class="mt-3">
<app-metadata-detail [tags]="externalSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item}}
</app-tag-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="externalSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item.name}}
</app-tag-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="externalSeries.staff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
<ng-template #itemTemplate let-item>
<div class="card mb-3" style="max-width: 180px;">
<div class="row g-0">
<div class="col-md-4">
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
</ng-container>
<ng-template #localPerson>
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
</ng-template>
</div>
<div class="col-md-8">
<div class="card-body">
<h6 class="card-title">{{item.name}}</h6>
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
</div>
</div>
</div>
</div>
</ng-template>
</app-metadata-detail>
</div>
</ng-container>
<ng-template #localSeriesBody>
<ng-container *ngIf="localSeries">
<span class="text-muted" style="font-size: 14px; color: lightgrey">{{localSeries.publicationStatus | publicationStatus}}</span>
<app-read-more [maxLength]="300" [text]="localSeries.summary"></app-read-more>
<div class="mt-3">
<app-metadata-detail [tags]="localSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item.title}}
</app-tag-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
<ng-template #itemTemplate let-item>
<app-tag-badge>
{{item.title}}
</app-tag-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="mt-3">
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
<ng-template #itemTemplate let-item>
<div class="card mb-3" style="max-width: 180px;">
<div class="row g-0">
<div class="col-md-4">
<i class="fa fa-user-circle align-self-center" style="font-size: 28px; margin-top: 24px; margin-left: 24px" aria-hidden="true"></i>
</div>
<div class="col-md-8">
<div class="card-body">
<h6 class="card-title">{{item.name}}</h6>
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
</div>
</div>
</div>
</div>
</ng-template>
</app-metadata-detail>
</div>
</ng-container>
</ng-template>
<app-loading [loading]="isLoading"></app-loading>
<a class="btn btn-primary col-12 " [href]="url" target="_blank" rel="noopener noreferrer">
{{t('series-preview-drawer.view-series')}}
</a>
</div>
</ng-container>

View file

@ -0,0 +1,10 @@
// You must add this on a component based drawer
:host {
height: 100%;
display: flex;
flex-direction: column;
}
::ng-deep .person-img {
margin-top: 24px; margin-left: 24px;
}

View file

@ -0,0 +1,87 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TranslocoDirective} from "@ngneat/transloco";
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {ExternalSeriesDetail, SeriesStaff} from "../../_models/series-detail/external-series-detail";
import {SeriesService} from "../../_services/series.service";
import {ImageComponent} from "../../shared/image/image.component";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
import {A11yClickDirective} from "../../shared/a11y-click.directive";
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
import {ImageService} from "../../_services/image.service";
import {PublicationStatusPipe} from "../../pipe/publication-status.pipe";
import {SeriesMetadata} from "../../_models/metadata/series-metadata";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
@Component({
selector: 'app-series-preview-drawer',
standalone: true,
imports: [CommonModule, TranslocoDirective, ImageComponent, LoadingComponent, SafeHtmlPipe, A11yClickDirective, MetadataDetailComponent, PersonBadgeComponent, TagBadgeComponent, PublicationStatusPipe, ReadMoreComponent],
templateUrl: './series-preview-drawer.component.html',
styleUrls: ['./series-preview-drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SeriesPreviewDrawerComponent implements OnInit {
@Input({required: true}) name!: string;
@Input() aniListId?: number;
@Input() malId?: number;
@Input() seriesId?: number;
@Input() libraryId: number = 0;
@Input({required: true}) isExternalSeries: boolean = true;
isLoading: boolean = true;
localStaff: Array<SeriesStaff> = [];
externalSeries: ExternalSeriesDetail | undefined;
localSeries: SeriesMetadata | undefined;
url: string = '';
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly seriesService = inject(SeriesService);
private readonly imageService = inject(ImageService);
private readonly cdRef = inject(ChangeDetectorRef);
get CoverUrl() {
if (this.isExternalSeries) {
if (this.externalSeries) return this.externalSeries.coverUrl;
return this.imageService.placeholderImage;
}
return this.imageService.getSeriesCoverImage(this.seriesId!);
}
ngOnInit() {
if (this.isExternalSeries) {
this.seriesService.getExternalSeriesDetails(this.aniListId, this.malId).subscribe(externalSeries => {
this.externalSeries = externalSeries;
this.isLoading = false;
if (this.externalSeries.siteUrl) {
this.url = this.externalSeries.siteUrl;
}
console.log('External Series Detail: ', this.externalSeries);
this.cdRef.markForCheck();
});
} else {
this.seriesService.getMetadata(this.seriesId!).subscribe(data => {
this.localSeries = data;
this.isLoading = false;
this.url = 'library/' + this.libraryId + '/series/' + this.seriesId;
this.localStaff = data.writers.map(p => {
return {name: p.name, role: 'Story & Art'} as SeriesStaff;
});
this.cdRef.markForCheck();
})
}
}
close() {
this.activeOffcanvas.close();
}
}

View file

@ -1,7 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
ElementRef, inject,
Input,
ViewChild
} from '@angular/core';
@ -9,9 +9,11 @@ import {CommonModule} from '@angular/common';
import {ExternalSeries} from "../../_models/series-detail/external-series";
import {RouterLinkActive} from "@angular/router";
import {ImageComponent} from "../../shared/image/image.component";
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {NgbActiveOffcanvas, NgbOffcanvas, NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {ReactiveFormsModule} from "@angular/forms";
import {TranslocoDirective} from "@ngneat/transloco";
import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-drawer/series-preview-drawer.component";
import {SeriesService} from "../../_services/series.service";
@Component({
selector: 'app-external-series-card',
@ -23,9 +25,23 @@ import {TranslocoDirective} from "@ngneat/transloco";
})
export class ExternalSeriesCardComponent {
@Input({required: true}) data!: ExternalSeries;
/**
* When clicking on the series, instead of opening, opens a preview drawer
*/
@Input() previewOnClick: boolean = false;
@ViewChild('link', {static: false}) link!: ElementRef<HTMLAnchorElement>;
private readonly offcanvasService = inject(NgbOffcanvas);
handleClick() {
if (this.previewOnClick) {
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
ref.componentInstance.isExternalSeries = true;
ref.componentInstance.aniListId = this.data.aniListId;
ref.componentInstance.malId = this.data.malId;
ref.componentInstance.name = this.data.name;
return;
}
if (this.link) {
this.link.nativeElement.click();
}

View file

@ -9,7 +9,7 @@ import {
Output
} from '@angular/core';
import {Router} from '@angular/router';
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
import {NgbModal, NgbOffcanvas} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {Series} from 'src/app/_models/series';
import {ImageService} from 'src/app/_services/image.service';
@ -23,6 +23,7 @@ import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../pipe/relationship.pipe";
import {Device} from "../../_models/device/device";
import {TranslocoService} from "@ngneat/transloco";
import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-drawer/series-preview-drawer.component";
function deepClone(obj: any): any {
if (obj === null || typeof obj !== 'object') {
@ -76,6 +77,10 @@ export class SeriesCardComponent implements OnInit, OnChanges {
* When a series card is shown on deck, a special actionable is added to the list
*/
@Input() isOnDeck: boolean = false;
/**
* Opens a drawer with a preview of the metadata for this series
*/
@Input() previewOnClick: boolean = false;
@Output() clicked = new EventEmitter<Series>();
/**
@ -92,6 +97,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
imageUrl: string = '';
private readonly translocoService = inject(TranslocoService);
private readonly offcanvasService = inject(NgbOffcanvas);
constructor(private router: Router, private cdRef: ChangeDetectorRef,
private seriesService: SeriesService, private toastr: ToastrService,
@ -234,6 +240,14 @@ export class SeriesCardComponent implements OnInit, OnChanges {
}
handleClick() {
if (this.previewOnClick) {
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
ref.componentInstance.isExternalSeries = false;
ref.componentInstance.seriesId = this.data.id;
ref.componentInstance.libraryId = this.data.libraryId;
ref.componentInstance.name = this.data.name;
return;
}
this.clicked.emit(this.data);
this.router.navigate(['library', this.libraryId, 'series', this.data?.id]);
}

View file

@ -11,6 +11,8 @@
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
</ng-container>
<ng-template #extrasDrawer let-offcanvas>
<div style="margin-top: 56px">
<div class="offcanvas-header">
@ -312,10 +314,10 @@
<div class="card-container row g-0" #container>
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [libraryId]="item.libraryId"></app-series-card>
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
</ng-container>
<ng-template #externalRec>
<app-external-series-card class="col-auto mt-2 mb-2" [data]="item"></app-external-series-card>
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
</ng-template>
</ng-container>
</div>
@ -325,14 +327,18 @@
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
<app-external-list-item [imageUrl]="imageService.getSeriesCoverImage(item.id)" imageWidth="130px" imageHeight="" [summary]="item.summary">
<ng-container title>
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
<span (click)="previewSeries(item, false); $event.stopPropagation(); $event.preventDefault();">
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
</span>
</ng-container>
</app-external-list-item>
</ng-container>
<ng-template #externalRec>
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
<ng-container title>
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
</span>
</ng-container>
</app-external-list-item>
</ng-template>

View file

@ -71,6 +71,10 @@ import { TagBadgeComponent } from '../../../shared/tag-badge/tag-badge.component
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {ExternalSeries} from "../../../_models/series-detail/external-series";
import {
SeriesPreviewDrawerComponent
} from "../../../_single-module/series-preview-drawer/series-preview-drawer.component";
interface RelatedSeriesPair {
series: Series;
@ -810,5 +814,22 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.cdRef.markForCheck();
}
protected readonly undefined = undefined;
previewSeries(item: Series | ExternalSeries, isExternal: boolean) {
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
ref.componentInstance.isExternalSeries = isExternal;
ref.componentInstance.name = item.name;
if (isExternal) {
const external = item as ExternalSeries;
ref.componentInstance.aniListId = external.aniListId;
ref.componentInstance.malId = external.malId;
} else {
const local = item as Series;
ref.componentInstance.seriesId = local.id;
ref.componentInstance.libraryId = local.libraryId;
}
}
}

View file

@ -1,4 +1,4 @@
<img #img class="lazyload img-placeholder" src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
<img #img class="lazyload img-placeholder {{classes}}" src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
[attr.data-src]="imageUrl"
(error)="imageService.updateErroredImage($event)"
aria-hidden="true"

View file

@ -64,6 +64,7 @@ export class ImageComponent implements OnChanges {
* If the image component should respond to cover updates
*/
@Input() processEvents: boolean = true;
@Input() classes: string = '';
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
private readonly destroyRef = inject(DestroyRef);

View file

@ -1,8 +1,15 @@
<div class="tagbadge cursor clickable" *ngIf="person !== undefined">
<div class="d-flex">
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>
<ng-container *ngIf="isStaff && staff.imageUrl && !staff.imageUrl.endsWith('default.jpg'); else localPerson">
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="staff.imageUrl"></app-image>
</ng-container>
<ng-template #localPerson>
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>
</ng-template>
<div class="flex-grow-1">
<span class="mt-0 mb-0">{{person.name}}</span>
<span class="mt-0 mb-0">
{{person.name}}
</span>
</div>
</div>
</div>

View file

@ -1,18 +1,28 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
import { Person } from '../../_models/metadata/person';
import {CommonModule} from "@angular/common";
import {SeriesStaff} from "../../_models/series-detail/external-series-detail";
import {ImageComponent} from "../image/image.component";
@Component({
selector: 'app-person-badge',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ImageComponent],
templateUrl: './person-badge.component.html',
styleUrls: ['./person-badge.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonBadgeComponent {
export class PersonBadgeComponent implements OnInit {
@Input({required: true}) person!: Person;
@Input({required: true}) person!: Person | SeriesStaff;
@Input() isStaff = false;
constructor() { }
private readonly cdRef = inject(ChangeDetectorRef);
staff!: SeriesStaff;
ngOnInit() {
this.staff = this.person as SeriesStaff;
this.cdRef.markForCheck();
}
}

View file

@ -1675,6 +1675,14 @@
"count-header": "Count"
},
"series-preview-drawer": {
"staff-label": "Staff",
"tags-label": "{{filter-field-pipe.tags}}",
"genres-label": "{{filter-field-pipe.genres}}",
"view-series": "View Series",
"vols-and-chapters": "{{volCount}} Volumes / {{chpCount}} Chapters"
},
"server-stats": {
"total-series-label": "Total Series",
"total-series-tooltip": "Total Series: {{count}}",

View file

@ -42,3 +42,8 @@ hr {
background-color: var(--primary-color);
}
.navbar-offset {
margin-top: 56px;
}