(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

@ -22,4 +22,5 @@ public static class EasyCacheProfiles
public const string KavitaPlusReviews = "kavita+reviews";
public const string KavitaPlusRecommendations = "kavita+recommendations";
public const string KavitaPlusRatings = "kavita+ratings";
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
}

View File

@ -1022,7 +1022,8 @@ public class OpdsController : BaseApiController
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/image")]
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId,
[FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
{
var userId = await GetUser(apiKey);
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));

View File

@ -10,6 +10,7 @@ using API.DTOs.Dashboard;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.Metadata;
using API.DTOs.Recommendation;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
@ -35,14 +36,17 @@ public class SeriesController : BaseApiController
private readonly ISeriesService _seriesService;
private readonly ILicenseService _licenseService;
private readonly ILocalizationService _localizationService;
private readonly IExternalMetadataService _externalMetadataService;
private readonly IEasyCachingProvider _ratingCacheProvider;
private readonly IEasyCachingProvider _reviewCacheProvider;
private readonly IEasyCachingProvider _recommendationCacheProvider;
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
public const string CacheKey = "recommendation_";
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
ISeriesService seriesService, ILicenseService licenseService,
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService)
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
{
_logger = logger;
_taskScheduler = taskScheduler;
@ -50,10 +54,12 @@ public class SeriesController : BaseApiController
_seriesService = seriesService;
_licenseService = licenseService;
_localizationService = localizationService;
_externalMetadataService = externalMetadataService;
_ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
_reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
}
/// <summary>
@ -576,6 +582,32 @@ public class SeriesController : BaseApiController
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-relationship"));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("external-series-detail")]
public async Task<ActionResult<ExternalSeriesDto>> GetExternalSeriesInfo(int? aniListId, long? malId)
{
if (!await _licenseService.HasActiveLicense())
{
return BadRequest();
}
var cacheKey = $"{CacheKey}-{aniListId ?? 0}-{malId ?? 0}";
var results = await _externalSeriesCacheProvider.GetAsync<ExternalSeriesDto>(cacheKey);
if (results.HasValue)
{
return Ok(results.Value);
}
try
{
var ret = await _externalMetadataService.GetExternalSeriesDetail(aniListId, malId);
await _externalSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(15));
return Ok(ret);
}
catch (Exception ex)
{
return BadRequest("Unable to load External Series details");
}
}
}

View File

@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace API.DTOs.Recommendation;
public class ExternalSeriesDetailDto
{
public string Name { get; set; }
public int? AniListId { get; set; }
public long? MALId { get; set; }
public IList<string> Synonyms { get; set; }
public PlusMediaFormat PlusMediaFormat { get; set; }
public string? SiteUrl { get; set; }
public string? CoverUrl { get; set; }
public IList<string> Genres { get; set; }
public IList<SeriesStaffDto> Staff { get; set; }
public IList<MetadataTagDto> Tags { get; set; }
public string? Summary { get; set; }
public int? VolumeCount { get; set; }
public int? ChapterCount { get; set; }
}

View File

@ -7,4 +7,6 @@ public class ExternalSeriesDto
public required string CoverUrl { get; set; }
public required string Url { get; set; }
public string? Summary { get; set; }
public int? AniListId { get; set; }
public long? MalId { get; set; }
}

View File

@ -0,0 +1,11 @@
namespace API.DTOs.Recommendation;
public class MetadataTagDto
{
public string Name { get; set; }
public string Description { get; private set; }
public int? Rank { get; private set; }
public bool IsGeneralSpoiler { get; private set; }
public bool IsMediaSpoiler { get; private set; }
public bool IsAdult { get; private set; }
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel;
namespace API.DTOs.Recommendation;
public enum PlusMediaFormat
{
[Description("Manga")]
Manga = 1,
[Description("Comic")]
Comic = 2,
[Description("LightNovel")]
LightNovel = 3,
[Description("Book")]
Book = 4
}

View File

@ -0,0 +1,11 @@
namespace API.DTOs.Recommendation;
public class SeriesStaffDto
{
public required string Name { get; set; }
public required string Url { get; set; }
public required string Role { get; set; }
public string? ImageUrl { get; set; }
public string? Gender { get; set; }
public string? Description { get; set; }
}

View File

@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace API.Extensions;
@ -73,6 +72,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<ILicenseService, LicenseService>();
services.AddScoped<IReviewService, ReviewService>();
services.AddScoped<IRatingService, RatingService>();
services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
services.AddSqLite();
services.AddSignalR(opt => opt.EnableDetailedErrors = true);
@ -89,6 +89,7 @@ public static class ApplicationServiceExtensions
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);
options.UseInMemory(EasyCacheProfiles.KavitaPlusRecommendations);
options.UseInMemory(EasyCacheProfiles.KavitaPlusRatings);
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
});
services.AddMemoryCache(options =>

View File

@ -550,8 +550,12 @@ public class BookService : IBookService
}
}
// Check if there is a SortTitle
// If this is a single book and not a collection, set publication status to Completed
if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath).Equals(Parser.DefaultVolume))
{
info.Number = "1";
info.Count = 1;
}
// Include regular Writer as well, for cases where there is no special tag
info.Writer = string.Join(",",

View File

@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Recommendation;
using API.Entities.Enums;
using API.Helpers.Builders;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
using Kavita.Common.Helpers;
using Microsoft.Extensions.Logging;
namespace API.Services.Plus;
public interface IExternalMetadataService
{
Task<ExternalSeriesDetailDto> GetExternalSeriesDetail(int? aniListId, long? malId);
}
public class ExternalMetadataService : IExternalMetadataService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ExternalMetadataService> _logger;
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
}
public async Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId)
{
if (!aniListId.HasValue && !malId.HasValue)
{
throw new KavitaException("Unable to find valid information from url for External Load");
}
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
return await GetSeriesDetail(license, aniListId, malId);
}
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(string license, int? anilistId, long? malId)
{
try
{
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/series/detail")
.WithHeader("Accept", "application/json")
.WithHeader("User-Agent", "Kavita")
.WithHeader("x-license-key", license)
.WithHeader("x-installId", HashUtil.ServerToken())
.WithHeader("x-kavita-version", BuildInfo.Version)
.WithHeader("Content-Type", "application/json")
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
.PostJsonAsync(new
{
AnilistId = anilistId,
MalId = malId,
})
.ReceiveJson<ExternalSeriesDetailDto>();
}
catch (Exception e)
{
_logger.LogError(e, "An error happened during the request to Kavita+ API");
}
return null;
}
}

View File

@ -10,9 +10,7 @@ using API.DTOs.Scrobbling;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Builders;
using API.Services.Tasks.Scanner.Parser;
using Flurl.Http;
using Kavita.Common;
using Kavita.Common.EnvironmentInfo;
@ -111,7 +109,9 @@ public class RecommendationService : IRecommendationService
Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name,
Url = rec.SiteUrl,
CoverUrl = rec.CoverUrl,
Summary = rec.Summary
Summary = rec.Summary,
AniListId = rec.AniListId,
MalId = rec.MalId
});
}

2419
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,16 +13,16 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^16.1.8",
"@angular/cdk": "^16.1.7",
"@angular/common": "^16.1.8",
"@angular/compiler": "^16.1.8",
"@angular/core": "^16.1.8",
"@angular/forms": "^16.1.8",
"@angular/localize": "^16.1.8",
"@angular/platform-browser": "^16.1.8",
"@angular/platform-browser-dynamic": "^16.1.8",
"@angular/router": "^16.1.8",
"@angular/animations": "^16.2.7",
"@angular/cdk": "^16.2.6",
"@angular/common": "^16.2.7",
"@angular/compiler": "^16.2.7",
"@angular/core": "^16.2.7",
"@angular/forms": "^16.2.7",
"@angular/localize": "^16.2.7",
"@angular/platform-browser": "^16.2.7",
"@angular/platform-browser-dynamic": "^16.2.7",
"@angular/router": "^16.2.7",
"@fortawesome/fontawesome-free": "^6.4.2",
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
"@iplab/ngx-file-upload": "^16.0.2",
@ -56,14 +56,14 @@
"zone.js": "^0.13.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^16.1.8",
"@angular-devkit/build-angular": "^16.2.4",
"@angular-eslint/builder": "^16.1.0",
"@angular-eslint/eslint-plugin": "^16.1.0",
"@angular-eslint/eslint-plugin-template": "^16.1.0",
"@angular-eslint/schematics": "^16.1.0",
"@angular-eslint/template-parser": "^16.1.0",
"@angular/cli": "^16.1.8",
"@angular/compiler-cli": "^16.1.8",
"@angular/cli": "^16.2.4",
"@angular/compiler-cli": "^16.2.7",
"@types/d3": "^7.4.0",
"@types/node": "^20.4.8",
"@typescript-eslint/eslint-plugin": "^6.3.0",

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;
}

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.8.5"
"version": "0.7.8.6"
},
"servers": [
{
@ -9107,6 +9107,53 @@
}
}
},
"/api/Series/external-series-detail": {
"get": {
"tags": [
"Series"
],
"parameters": [
{
"name": "aniListId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "malId",
"in": "query",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ExternalSeriesDto"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExternalSeriesDto"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ExternalSeriesDto"
}
}
}
}
}
}
},
"/api/Server/clear-cache": {
"post": {
"tags": [
@ -13767,6 +13814,16 @@
"summary": {
"type": "string",
"nullable": true
},
"aniListId": {
"type": "integer",
"format": "int32",
"nullable": true
},
"malId": {
"type": "integer",
"format": "int64",
"nullable": true
}
},
"additionalProperties": false