Chapter/Issue level Reviews and Ratings (#3778)

Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-04-29 18:53:24 +02:00 committed by GitHub
parent 3b8997e46e
commit 4f7625ea77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 5097 additions and 497 deletions

View file

@ -0,0 +1,9 @@
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "./rating";
export type ChapterDetailPlus = {
rating: number;
hasBeenRated: boolean;
reviews: UserReview[];
ratings: Rating[];
};

View file

@ -1,9 +1,15 @@
import {ScrobbleProvider} from "../_services/scrobbling.service";
export enum RatingAuthority {
User = 0,
Critic = 1,
}
export interface Rating {
averageScore: number;
meanScore: number;
favoriteCount: number;
provider: ScrobbleProvider;
providerUrl: string | undefined;
authority: RatingAuthority;
}

View file

@ -1,8 +1,9 @@
import { Injectable } from '@angular/core';
import {Injectable} from '@angular/core';
import {environment} from "../../environments/environment";
import { HttpClient } from "@angular/common/http";
import {HttpClient} from "@angular/common/http";
import {Chapter} from "../_models/chapter";
import {TextResonse} from "../_types/text-response";
import {ChapterDetailPlus} from "../_models/chapter-detail-plus";
@Injectable({
providedIn: 'root'
@ -29,4 +30,8 @@ export class ChapterService {
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
}
chapterDetailPlus(seriesId: number, chapterId: number) {
return this.httpClient.get<ChapterDetailPlus>(this.baseUrl + `chapter/chapter-detail-plus?chapterId=${chapterId}&seriesId=${seriesId}`);
}
}

View file

@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import {UserReview} from "../_single-module/review-card/user-review";
import {environment} from "../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {Rating} from "../_models/rating";
@Injectable({
providedIn: 'root'
})
export class ReviewService {
private baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
deleteReview(seriesId: number, chapterId?: number) {
if (chapterId) {
return this.httpClient.delete(this.baseUrl + `review/chapter?chapterId=${chapterId}`);
}
return this.httpClient.delete(this.baseUrl + `review/series?seriesId=${seriesId}`);
}
updateReview(seriesId: number, body: string, chapterId?: number) {
if (chapterId) {
return this.httpClient.post<UserReview>(this.baseUrl + `review/chapter`, {
seriesId, chapterId, body
});
}
return this.httpClient.post<UserReview>(this.baseUrl + 'review/series', {
seriesId, body
});
}
updateRating(seriesId: number, userRating: number, chapterId?: number) {
if (chapterId) {
return this.httpClient.post(this.baseUrl + 'rating/chapter', {
seriesId, chapterId, userRating
})
}
return this.httpClient.post(this.baseUrl + 'rating/series', {
seriesId, userRating
})
}
overallRating(seriesId: number, chapterId?: number) {
if (chapterId) {
return this.httpClient.get<Rating>(this.baseUrl + `rating/overall-chapter?chapterId=${chapterId}`);
}
return this.httpClient.get<Rating>(this.baseUrl + `rating/overall-series?seriesId=${seriesId}`);
}
}

View file

@ -203,27 +203,9 @@ export class SeriesService {
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
}
deleteReview(seriesId: number) {
return this.httpClient.delete(this.baseUrl + 'review?seriesId=' + seriesId);
}
updateReview(seriesId: number, body: string) {
return this.httpClient.post<UserReview>(this.baseUrl + 'review', {
seriesId, body
});
}
getReviews(seriesId: number) {
return this.httpClient.get<Array<UserReview>>(this.baseUrl + 'review?seriesId=' + seriesId);
}
getRatings(seriesId: number) {
return this.httpClient.get<Array<Rating>>(this.baseUrl + 'rating?seriesId=' + seriesId);
}
getOverallRating(seriesId: number) {
return this.httpClient.get<Rating>(this.baseUrl + 'rating/overall?seriesId=' + seriesId);
}
removeFromOnDeck(seriesId: number) {
return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {});

View file

@ -28,4 +28,5 @@ export class VolumeService {
updateVolume(volume: any) {
return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse);
}
}

View file

@ -34,7 +34,11 @@
<div class="d-flex pt-3 justify-content-between">
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
@if (item.series.plusMediaFormat === PlusMediaFormat.Comic) {
<span class="me-1">{{t('issue-count', {num: item.series.chapters})}}</span>
} @else {
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
}
} @else {
<span class="me-1">{{t('releasing')}}</span>
}

View file

@ -14,6 +14,7 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {PlusMediaFormat} from "../../_models/series-detail/external-series-detail";
@Component({
selector: 'app-match-series-result-item',
@ -47,4 +48,5 @@ export class MatchSeriesResultItemComponent {
this.selected.emit(this.item);
}
protected readonly PlusMediaFormat = PlusMediaFormat;
}

View file

@ -27,6 +27,9 @@
{{review.username}}
}
{{(isMyReview ? '' : review.username | defaultValue:'')}}
@if (review.authority === RatingAuthority.Critic) {
({{t('critic')}})
}
</div>
@if (review.isExternal){
<span class="review-score">{{t('rating-percentage', {r: review.score})}}</span>

View file

@ -13,15 +13,13 @@ import {UserReview} from "./user-review";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {ReviewCardModalComponent} from "../review-card-modal/review-card-modal.component";
import {AccountService} from "../../_services/account.service";
import {
ReviewSeriesModalCloseEvent,
ReviewSeriesModalComponent
} from "../review-series-modal/review-series-modal.component";
import {ReviewModalCloseEvent, ReviewModalComponent} from "../review-modal/review-modal.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {ProviderImagePipe} from "../../_pipes/provider-image.pipe";
import {TranslocoDirective} from "@jsverse/transloco";
import {ScrobbleProvider} from "../../_services/scrobbling.service";
import {RatingAuthority} from "../../_models/rating";
@Component({
selector: 'app-review-card',
@ -35,7 +33,7 @@ export class ReviewCardComponent implements OnInit {
protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) review!: UserReview;
@Output() refresh = new EventEmitter<ReviewSeriesModalCloseEvent>();
@Output() refresh = new EventEmitter<ReviewModalCloseEvent>();
isMyReview: boolean = false;
@ -44,7 +42,7 @@ export class ReviewCardComponent implements OnInit {
ngOnInit() {
this.accountService.currentUser$.subscribe(u => {
if (u) {
this.isMyReview = this.review.username === u.username;
this.isMyReview = this.review.username === u.username && !this.review.isExternal;
this.cdRef.markForCheck();
}
});
@ -53,16 +51,19 @@ export class ReviewCardComponent implements OnInit {
showModal() {
let component;
if (this.isMyReview) {
component = ReviewSeriesModalComponent;
component = ReviewModalComponent;
} else {
component = ReviewCardModalComponent;
}
const ref = this.modalService.open(component, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.review = this.review;
ref.closed.subscribe((res: ReviewSeriesModalCloseEvent | undefined) => {
ref.closed.subscribe((res: ReviewModalCloseEvent | undefined) => {
if (res) {
this.refresh.emit(res);
}
})
}
protected readonly RatingAuthority = RatingAuthority;
}

View file

@ -1,8 +1,11 @@
import {ScrobbleProvider} from "../../_services/scrobbling.service";
import {RatingAuthority} from "../../_models/rating";
export interface UserReview {
seriesId: number;
libraryId: number;
chapterId?: number;
score: number;
username: string;
body: string;
@ -11,4 +14,5 @@ export interface UserReview {
bodyJustText?: string;
siteUrl?: string;
provider: ScrobbleProvider;
authority: RatingAuthority;
}

View file

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'review-series-modal'">
<ng-container *transloco="let t; read:'review-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
@ -8,6 +8,11 @@
</div>
<div class="modal-body">
<form [formGroup]="reviewGroup">
<!--Not in sync with one at the top of the detail page, bad UX for now -->
<!--<ngx-stars [initialStars]="review.rating" (ratingOutput)="updateRating($event)" [size]="2"
[maxStars]="5" [color]="starColor"></ngx-stars>-->
<div class="row g-0 mt-2">
<label for="review" class="form-label">{{t('review-label')}}</label>
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" [minlength]="minLength"

View file

@ -6,30 +6,35 @@ import {UserReview} from "../review-card/user-review";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ConfirmService} from "../../shared/confirm.service";
import {ToastrService} from "ngx-toastr";
import {ChapterService} from "../../_services/chapter.service";
import {of} from "rxjs";
import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../_services/theme.service";
import {ReviewService} from "../../_services/review.service";
export enum ReviewSeriesModalCloseAction {
export enum ReviewModalCloseAction {
Create,
Edit,
Delete,
Close
}
export interface ReviewSeriesModalCloseEvent {
export interface ReviewModalCloseEvent {
success: boolean,
review: UserReview;
action: ReviewSeriesModalCloseAction
action: ReviewModalCloseAction
}
@Component({
selector: 'app-review-series-modal',
imports: [ReactiveFormsModule, TranslocoDirective],
templateUrl: './review-series-modal.component.html',
styleUrls: ['./review-series-modal.component.scss'],
imports: [ReactiveFormsModule, TranslocoDirective, NgxStarsModule],
templateUrl: './review-modal.component.html',
styleUrls: ['./review-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReviewSeriesModalComponent implements OnInit {
export class ReviewModalComponent implements OnInit {
protected readonly modal = inject(NgbActiveModal);
private readonly seriesService = inject(SeriesService);
private readonly reviewService = inject(ReviewService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly confirmService = inject(ConfirmService);
private readonly toastr = inject(ToastrService);
@ -46,23 +51,27 @@ export class ReviewSeriesModalComponent implements OnInit {
}
close() {
this.modal.close({success: false, review: this.review, action: ReviewSeriesModalCloseAction.Close});
this.modal.close({success: false, review: this.review, action: ReviewModalCloseAction.Close});
}
async delete() {
if (!await this.confirmService.confirm(translate('toasts.delete-review'))) return;
this.seriesService.deleteReview(this.review.seriesId).subscribe(() => {
this.reviewService.deleteReview(this.review.seriesId, this.review.chapterId).subscribe(() => {
this.toastr.success(translate('toasts.review-deleted'));
this.modal.close({success: true, review: this.review, action: ReviewSeriesModalCloseAction.Delete});
this.modal.close({success: true, review: this.review, action: ReviewModalCloseAction.Delete});
});
}
save() {
const model = this.reviewGroup.value;
if (model.reviewBody.length < this.minLength) {
return;
}
this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit});
this.reviewService.updateReview(this.review.seriesId, model.reviewBody, this.review.chapterId).subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewModalCloseAction.Edit});
});
}
}

View file

@ -0,0 +1,17 @@
<div class="mb-3" *transloco="let t;prefix:'reviews'">
<app-carousel-reel [items]="userReviews" [alwaysShow]="true" [title]="t('user-reviews-local')"
iconClasses="fa-solid fa-{{this.getUserReviews().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]=true (sectionClick)="openReviewModal()">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3" *transloco="let t;prefix:'reviews'">
<app-carousel-reel [items]="plusReviews" [alwaysShow]="false" [title]="t('user-reviews-plus')">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>

View file

@ -0,0 +1,103 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnInit} from '@angular/core';
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
import {ReviewCardComponent} from "../review-card/review-card.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {UserReview} from "../review-card/user-review";
import {User} from "../../_models/user";
import {AccountService} from "../../_services/account.service";
import {
ReviewModalComponent, ReviewModalCloseAction,
ReviewModalCloseEvent
} from "../review-modal/review-modal.component";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
import {Series} from "../../_models/series";
import {Volume} from "../../_models/volume";
import {Chapter} from "../../_models/chapter";
@Component({
selector: 'app-reviews',
imports: [
CarouselReelComponent,
ReviewCardComponent,
TranslocoDirective
],
templateUrl: './reviews.component.html',
styleUrl: './reviews.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReviewsComponent {
@Input({required: true}) userReviews!: Array<UserReview>;
@Input({required: true}) plusReviews!: Array<UserReview>;
@Input({required: true}) series!: Series;
@Input() volumeId: number | undefined;
@Input() chapter: Chapter | undefined;
user: User | undefined;
constructor(
private accountService: AccountService,
private modalService: NgbModal,
private cdRef: ChangeDetectorRef) {
this.accountService.currentUser$.subscribe(user => {
if (user) {
this.user = user;
}
});
}
openReviewModal() {
const userReview = this.getUserReviews();
const modalRef = this.modalService.open(ReviewModalComponent, DefaultModalOptions);
if (userReview.length > 0) {
modalRef.componentInstance.review = userReview[0];
} else {
modalRef.componentInstance.review = {
seriesId: this.series.id,
volumeId: this.volumeId,
chapterId: this.chapter?.id,
tagline: '',
body: ''
};
}
modalRef.closed.subscribe((closeResult) => {
this.updateOrDeleteReview(closeResult);
});
}
updateOrDeleteReview(closeResult: ReviewModalCloseEvent) {
if (closeResult.action === ReviewModalCloseAction.Close) return;
const index = this.userReviews.findIndex(r => r.username === closeResult.review!.username);
if (closeResult.action === ReviewModalCloseAction.Edit) {
if (index === -1 ) {
this.userReviews = [closeResult.review, ...this.userReviews];
this.cdRef.markForCheck();
return;
}
this.userReviews[index] = closeResult.review;
this.cdRef.markForCheck();
return;
}
if (closeResult.action === ReviewModalCloseAction.Delete) {
this.userReviews = [...this.userReviews.filter(r => r.username !== closeResult.review!.username)];
this.cdRef.markForCheck();
return;
}
}
getUserReviews() {
if (!this.user) {
return [];
}
return this.userReviews.filter(r => r.username === this.user?.username && !r.isExternal);
}
}

View file

@ -27,15 +27,16 @@
<!-- Rating goes here (after I implement support for rating individual issues -->
<!-- <div class="mt-2 mb-2">-->
<!-- <app-external-rating [seriesId]="series.id"-->
<!-- [ratings]="[]"-->
<!-- [userRating]="series.userRating"-->
<!-- [hasUserRated]="series.hasUserRated"-->
<!-- [libraryType]="libraryType!">-->
<!-- </app-external-rating>-->
<!-- </div>-->
<div class="mt-2 mb-2">
<app-external-rating [seriesId]="series.id"
[ratings]="ratings"
[userRating]="rating"
[hasUserRated]="hasBeenRated"
[libraryType]="libraryType!"
[chapterId]="chapterId"
>
</app-external-rating>
</div>
<div class="mt-3 mb-3">
<div class="row g-0">
@ -175,6 +176,19 @@
</li>
}
<li [ngbNavItem]="TabID.Reviews">
<a ngbNavLink>
{{t('reviews-tab')}}
<span class="badge rounded-pill text-bg-secondary">{{userReviews.length + plusReviews.length}}</span>
</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Reviews; prefetch on idle) {
<app-reviews [userReviews]="userReviews" [plusReviews]="plusReviews"
[series]="series" [chapter]="chapter" [volumeId]="chapter.volumeId" />
}
</ng-template>
</li>
@if(readingLists.length > 0) {
<li [ngbNavItem]="TabID.Related">
<a ngbNavLink>{{t('related-tab')}}</a>

View file

@ -1,23 +1,28 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, DestroyRef,
Component,
DestroyRef,
ElementRef,
inject,
OnInit,
ViewChild
} from '@angular/core';
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
import {AsyncPipe, DatePipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
import {LoadingComponent} from "../shared/loading/loading.component";
import {
NgbDropdown,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle, NgbModal,
NgbNav, NgbNavChangeEvent,
NgbNavContent, NgbNavItem,
NgbNavLink, NgbNavOutlet,
NgbDropdownToggle,
NgbModal,
NgbNav,
NgbNavChangeEvent,
NgbNavContent,
NgbNavItem,
NgbNavLink,
NgbNavOutlet,
NgbTooltip
} from "@ng-bootstrap/ng-bootstrap";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
@ -65,45 +70,52 @@ import {ActionService} from "../_services/action.service";
import {DefaultDatePipe} from "../_pipes/default-date.pipe";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {UserReview} from "../_single-module/review-card/user-review";
import {User} from "../_models/user";
import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {Rating} from "../_models/rating";
enum TabID {
Related = 'related-tab',
Reviews = 'review-tab', // Only applicable for books
Reviews = 'review-tab',
Details = 'details-tab'
}
@Component({
selector: 'app-chapter-detail',
imports: [
AsyncPipe,
CardActionablesComponent,
LoadingComponent,
NgbDropdown,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle,
NgbNav,
NgbNavContent,
NgbNavLink,
NgbTooltip,
VirtualScrollerModule,
NgStyle,
NgClass,
TranslocoDirective,
ReadMoreComponent,
NgbNavItem,
NgbNavOutlet,
DetailsTabComponent,
RouterLink,
EntityTitleComponent,
RelatedTabComponent,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
DatePipe,
DefaultDatePipe,
CoverImageComponent
],
imports: [
AsyncPipe,
CardActionablesComponent,
LoadingComponent,
NgbDropdown,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdownToggle,
NgbNav,
NgbNavContent,
NgbNavLink,
NgbTooltip,
VirtualScrollerModule,
NgStyle,
NgClass,
TranslocoDirective,
ReadMoreComponent,
NgbNavItem,
NgbNavOutlet,
DetailsTabComponent,
RouterLink,
EntityTitleComponent,
RelatedTabComponent,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
DatePipe,
DefaultDatePipe,
CoverImageComponent,
ReviewsComponent,
ExternalRatingComponent
],
templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@ -138,6 +150,8 @@ export class ChapterDetailComponent implements OnInit {
protected readonly TabID = TabID;
protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint;
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -151,6 +165,12 @@ export class ChapterDetailComponent implements OnInit {
series: Series | null = null;
libraryType: LibraryType | null = null;
hasReadingProgress = false;
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
rating: number = 0;
ratings: Array<Rating> = [];
hasBeenRated: boolean = false;
weblinks: Array<string> = [];
activeTabId = TabID.Details;
/**
@ -163,6 +183,7 @@ export class ChapterDetailComponent implements OnInit {
mobileSeriesImgBackground: string | undefined;
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
user: User | undefined;
get ScrollingBlockHeight() {
if (this.scrollingBlock === undefined) return 'calc(var(--vh)*100)';
@ -177,6 +198,12 @@ export class ChapterDetailComponent implements OnInit {
ngOnInit() {
this.accountService.currentUser$.subscribe(user => {
if (user) {
this.user = user;
}
});
const seriesId = this.route.snapshot.paramMap.get('seriesId');
const libraryId = this.route.snapshot.paramMap.get('libraryId');
const chapterId = this.route.snapshot.paramMap.get('chapterId');
@ -211,7 +238,8 @@ export class ChapterDetailComponent implements OnInit {
forkJoin({
series: this.seriesService.getSeries(this.seriesId),
chapter: this.chapterService.getChapterMetadata(this.chapterId),
libraryType: this.libraryService.getLibraryType(this.libraryId)
libraryType: this.libraryService.getLibraryType(this.libraryId),
chapterDetail: this.chapterService.chapterDetailPlus(this.seriesId, this.chapterId),
}).subscribe(results => {
if (results.chapter === null) {
@ -223,6 +251,11 @@ export class ChapterDetailComponent implements OnInit {
this.chapter = results.chapter;
this.weblinks = this.chapter.webLinks.split(',');
this.libraryType = results.libraryType;
this.userReviews = results.chapterDetail.reviews.filter(r => !r.isExternal);
this.plusReviews = results.chapterDetail.reviews.filter(r => r.isExternal);
this.rating = results.chapterDetail.rating;
this.hasBeenRated = results.chapterDetail.hasBeenRated;
this.ratings = results.chapterDetail.ratings;
this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor);
@ -246,6 +279,7 @@ export class ChapterDetailComponent implements OnInit {
this.showDetailsTab = hasAnyCast(this.chapter) || (this.chapter.genres || []).length > 0 ||
(this.chapter.tags || []).length > 0 || this.chapter.webLinks.length > 0;
this.isLoading = false;
this.cdRef.markForCheck();
});
@ -361,7 +395,4 @@ export class ChapterDetailComponent implements OnInit {
break;
}
}
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;
}

View file

@ -17,7 +17,7 @@
@for (rating of ratings; track rating.provider + rating.averageScore) {
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
[popoverTitle]="rating.provider | scrobbleProviderName" popoverClass="sm-popover">
[popoverTitle]="(rating.provider | scrobbleProviderName) + getAuthorityTitle(rating)" popoverClass="sm-popover">
<span class="badge rounded-pill me-1">
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
{{rating.averageScore}}%
@ -70,6 +70,7 @@
</div>
}
@if (rating.providerUrl) {
<a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a>
}

View file

@ -8,8 +8,7 @@ import {
OnInit,
ViewEncapsulation
} from '@angular/core';
import {SeriesService} from "../../../_services/series.service";
import {Rating} from "../../../_models/rating";
import {Rating, RatingAuthority} from "../../../_models/rating";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {NgbModal, NgbPopover} from "@ng-bootstrap/ng-bootstrap";
import {LoadingComponent} from "../../../shared/loading/loading.component";
@ -18,12 +17,13 @@ import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../../_services/theme.service";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {ImageComponent} from "../../../shared/image/image.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
import {ImageService} from "../../../_services/image.service";
import {AsyncPipe, NgOptimizedImage, NgTemplateOutlet} from "@angular/common";
import {RatingModalComponent} from "../rating-modal/rating-modal.component";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {ReviewService} from "../../../_services/review.service";
@Component({
selector: 'app-external-rating',
@ -37,7 +37,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
export class ExternalRatingComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly seriesService = inject(SeriesService);
private readonly reviewService = inject(ReviewService);
private readonly themeService = inject(ThemeService);
public readonly utilityService = inject(UtilityService);
public readonly destroyRef = inject(DestroyRef);
@ -47,6 +47,7 @@ export class ExternalRatingComponent implements OnInit {
protected readonly Breakpoint = Breakpoint;
@Input({required: true}) seriesId!: number;
@Input() chapterId: number | undefined;
@Input({required: true}) userRating!: number;
@Input({required: true}) hasUserRated!: boolean;
@Input({required: true}) libraryType!: LibraryType;
@ -58,11 +59,13 @@ export class ExternalRatingComponent implements OnInit {
starColor = this.themeService.getCssVariable('--rating-star-color');
ngOnInit() {
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
this.reviewService.overallRating(this.seriesId, this.chapterId).subscribe(r => {
this.overallRating = r.averageScore;
});
}
updateRating(rating: number) {
this.seriesService.updateRating(this.seriesId, rating).subscribe(() => {
this.reviewService.updateRating(this.seriesId, rating, this.chapterId).subscribe(() => {
this.userRating = rating;
this.hasUserRated = true;
this.cdRef.markForCheck();
@ -81,4 +84,14 @@ export class ExternalRatingComponent implements OnInit {
this.cdRef.markForCheck();
});
}
getAuthorityTitle(rating: Rating) {
if (rating.authority === RatingAuthority.Critic) {
return ` (${translate('external-rating.critic')})`;
}
return '';
}
protected readonly RatingAuthority = RatingAuthority;
}

View file

@ -36,7 +36,6 @@
[mangaFormat]="series.format">
</app-metadata-detail-row>
<!-- Rating goes here (after I implement support for rating individual issues -->
<div class="mt-2 mb-2">
<app-external-rating [seriesId]="series.id"
[ratings]="ratings"
@ -315,25 +314,8 @@
</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Reviews; prefetch on idle) {
<div class="mb-3">
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-local')"
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]="true" (sectionClick)="openReviewModal()">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
<div class="mb-3">
<app-carousel-reel [items]="plusReviews" [alwaysShow]="false" [title]="t('user-reviews-plus')">
<ng-template #carouselItem let-item let-position="idx">
<app-review-card [review]="item"></app-review-card>
</ng-template>
</app-carousel-reel>
</div>
<app-reviews [userReviews]="reviews" [plusReviews]="plusReviews" [series]="series" />
}
</ng-template>
</li>

View file

@ -62,10 +62,10 @@ import {ReadingListService} from 'src/app/_services/reading-list.service';
import {ScrollService} from 'src/app/_services/scroll.service';
import {SeriesService} from 'src/app/_services/series.service';
import {
ReviewSeriesModalCloseAction,
ReviewSeriesModalCloseEvent,
ReviewSeriesModalComponent
} from '../../../_single-module/review-series-modal/review-series-modal.component';
ReviewModalCloseAction,
ReviewModalCloseEvent,
ReviewModalComponent
} from '../../../_single-module/review-modal/review-modal.component';
import {PageLayoutMode} from 'src/app/_models/page-layout-mode';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {UserReview} from "../../../_single-module/review-card/user-review";
@ -116,6 +116,7 @@ import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {LicenseService} from "../../../_services/license.service";
import {PageBookmark} from "../../../_models/readers/page-bookmark";
import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event";
import {ReviewsComponent} from "../../../_single-module/reviews/reviews.component";
enum TabID {
@ -140,14 +141,14 @@ interface StoryLineItem {
templateUrl: './series-detail.component.html',
styleUrls: ['./series-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle,
NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
NgbDropdownItem, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent,
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
NgClass, AsyncPipe, DetailsTabComponent, ChapterCardComponent,
VolumeCardComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent,
PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent]
imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle,
NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
NgbDropdownItem, BulkOperationsComponent,
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
NgClass, AsyncPipe, DetailsTabComponent, ChapterCardComponent,
VolumeCardComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent,
PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent, ReviewsComponent]
})
export class SeriesDetailComponent implements OnInit, AfterContentChecked {
@ -1137,56 +1138,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
});
}
getUserReview() {
return this.reviews.filter(r => r.username === this.user?.username && !r.isExternal);
}
openReviewModal() {
const userReview = this.getUserReview();
const modalRef = this.modalService.open(ReviewSeriesModalComponent, DefaultModalOptions);
modalRef.componentInstance.series = this.series;
if (userReview.length > 0) {
modalRef.componentInstance.review = userReview[0];
} else {
modalRef.componentInstance.review = {
seriesId: this.series.id,
tagline: '',
body: ''
};
}
modalRef.closed.subscribe((closeResult) => {
this.updateOrDeleteReview(closeResult);
});
}
updateOrDeleteReview(closeResult: ReviewSeriesModalCloseEvent) {
if (closeResult.action === ReviewSeriesModalCloseAction.Close) return;
const index = this.reviews.findIndex(r => r.username === closeResult.review!.username);
if (closeResult.action === ReviewSeriesModalCloseAction.Edit) {
if (index === -1 ) {
// A new series was added:
this.reviews = [closeResult.review, ...this.reviews];
this.cdRef.markForCheck();
return;
}
// An edit occurred
this.reviews[index] = closeResult.review;
this.cdRef.markForCheck();
return;
}
if (closeResult.action === ReviewSeriesModalCloseAction.Delete) {
// An edit occurred
this.reviews = [...this.reviews.filter(r => r.username !== closeResult.review!.username)];
this.cdRef.markForCheck();
return;
}
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {

View file

@ -29,17 +29,17 @@
[mangaFormat]="series.format">
</app-metadata-detail-row>
<!-- Rating goes here (after I implement support for rating individual issues -->
<!-- @if (libraryType !== null && series) {-->
<!-- <div class="mt-2 mb-2">-->
<!-- <app-external-rating [seriesId]="series.id"-->
<!-- [ratings]="[]"-->
<!-- [userRating]="series.userRating"-->
<!-- [hasUserRated]="series.hasUserRated"-->
<!-- [libraryType]="libraryType">-->
<!-- </app-external-rating>-->
<!-- </div>-->
<!-- }-->
@if (libraryType !== null && series && volume.chapters.length === 1) {
<div class="mt-2 mb-2">
<app-external-rating [seriesId]="series.id"
[ratings]="[]"
[userRating]="rating"
[hasUserRated]="hasBeenRated"
[libraryType]="libraryType"
[chapterId]="volume.chapters[0].id"
/>
</div>
}
<div class="mt-2 mb-3">
<div class="row g-0">
@ -191,6 +191,21 @@
</li>
}
@if (volume.chapters.length === 1) {
<li [ngbNavItem]="TabID.Reviews">
<a ngbNavLink>
{{t('reviews-tab')}}
<span class="badge rounded-pill text-bg-secondary">{{userReviews.length + plusReviews.length}}</span>
</a>
<ng-template ngbNavContent>
@defer (when activeTabId === TabID.Reviews; prefetch on idle) {
<app-reviews [userReviews]="userReviews" [plusReviews]="plusReviews"
[series]="series" [volumeId]="volumeId" [chapter]="volume.chapters[0]" />
}
</ng-template>
</li>
}
<li [ngbNavItem]="TabID.Details" id="details-tab">
<a ngbNavLink>{{t('details-tab')}}</a>
<ng-template ngbNavContent>

View file

@ -8,7 +8,7 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, Location} from "@angular/common";
import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
import {ActivatedRoute, Router, RouterLink} from "@angular/router";
import {ImageService} from "../_services/image.service";
import {SeriesService} from "../_services/series.service";
@ -54,9 +54,7 @@ import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
import {
EditVolumeModalComponent
} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
import {Genre} from "../_models/metadata/genre";
import {Tag} from "../_models/tag";
import {RelatedTabComponent} from "../_single-module/related-tab/related-tab.component";
@ -78,6 +76,10 @@ import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/ed
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options";
import {UserReview} from "../_single-module/review-card/user-review";
import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {ChapterService} from "../_services/chapter.service";
enum TabID {
@ -119,36 +121,38 @@ interface VolumeCast extends IHasCast {
@Component({
selector: 'app-volume-detail',
imports: [
LoadingComponent,
NgbNavOutlet,
DetailsTabComponent,
NgbNavItem,
NgbNavLink,
NgbNavContent,
NgbNav,
ReadMoreComponent,
AsyncPipe,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdown,
NgbDropdownToggle,
EntityTitleComponent,
RouterLink,
NgbTooltip,
NgStyle,
NgClass,
TranslocoDirective,
VirtualScrollerModule,
ChapterCardComponent,
RelatedTabComponent,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
CardActionablesComponent,
BulkOperationsComponent,
CoverImageComponent
],
imports: [
LoadingComponent,
NgbNavOutlet,
DetailsTabComponent,
NgbNavItem,
NgbNavLink,
NgbNavContent,
NgbNav,
ReadMoreComponent,
AsyncPipe,
NgbDropdownItem,
NgbDropdownMenu,
NgbDropdown,
NgbDropdownToggle,
EntityTitleComponent,
RouterLink,
NgbTooltip,
NgStyle,
NgClass,
TranslocoDirective,
VirtualScrollerModule,
ChapterCardComponent,
RelatedTabComponent,
BadgeExpanderComponent,
MetadataDetailRowComponent,
DownloadButtonComponent,
CardActionablesComponent,
BulkOperationsComponent,
CoverImageComponent,
ReviewsComponent,
ExternalRatingComponent
],
templateUrl: './volume-detail.component.html',
styleUrl: './volume-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@ -176,6 +180,7 @@ export class VolumeDetailComponent implements OnInit {
private readonly readingListService = inject(ReadingListService);
private readonly messageHub = inject(MessageHubService);
private readonly location = inject(Location);
private readonly chapterService = inject(ChapterService);
protected readonly AgeRating = AgeRating;
@ -196,6 +201,13 @@ export class VolumeDetailComponent implements OnInit {
libraryType: LibraryType | null = null;
activeTabId = TabID.Chapters;
readingLists: ReadingList[] = [];
// Only populated if the volume has exactly one chapter
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
rating: number = 0;
hasBeenRated: boolean = false;
mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false;
@ -374,7 +386,7 @@ export class VolumeDetailComponent implements OnInit {
forkJoin({
series: this.seriesService.getSeries(this.seriesId),
volume: this.volumeService.getVolumeMetadata(this.volumeId),
libraryType: this.libraryService.getLibraryType(this.libraryId)
libraryType: this.libraryService.getLibraryType(this.libraryId),
}).subscribe(results => {
if (results.volume === null) {
@ -386,6 +398,15 @@ export class VolumeDetailComponent implements OnInit {
this.volume = results.volume;
this.libraryType = results.libraryType;
if (this.volume.chapters.length === 1) {
this.chapterService.chapterDetailPlus(this.seriesId, this.volume.chapters[0].id).subscribe(detail => {
this.userReviews = detail.reviews.filter(r => !r.isExternal);
this.plusReviews = detail.reviews.filter(r => r.isExternal);
this.rating = detail.rating;
this.hasBeenRated = detail.hasBeenRated;
});
}
this.themeService.setColorScape(this.volume!.primaryColor, this.volume!.secondaryColor);
// Set up the download in progress

View file

@ -67,8 +67,12 @@
"spoiler": {
"click-to-show": "Spoiler, click to show"
},
"reviews": {
"user-reviews-local": "Local Reviews",
"user-reviews-plus": "External Reviews"
},
"review-series-modal": {
"review-modal": {
"title": "Edit Review",
"review-label": "Review",
"close": "{{common.close}}",
@ -89,7 +93,8 @@
"your-review": "This is your review",
"external-review": "External Review",
"local-review": "Local Review",
"rating-percentage": "Rating {{r}}%"
"rating-percentage": "Rating {{r}}%",
"critic": "critic"
},
"want-to-read": {
@ -958,9 +963,6 @@
"no-chapters": "There are no chapters to this volume. Cannot read.",
"cover-change": "It can take up to a minute for your browser to refresh the image. Until then, the old image may be shown on some pages.",
"user-reviews-local": "Local Reviews",
"user-reviews-plus": "External Reviews",
"writers-title": "{{metadata-fields.writers-title}}",
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
"characters-title": "{{metadata-fields.characters-title}}",
@ -1010,6 +1012,7 @@
"match-series-result-item": {
"volume-count": "{{server-stats.volume-count}}",
"chapter-count": "{{common.chapter-count}}",
"issue-count": "{{common.issue-count}}",
"releasing": "Releasing",
"details": "View page",
"updating-metadata-status": "Updating Metadata"
@ -1047,7 +1050,8 @@
"entry-label": "See Details",
"kavita-tooltip": "Your Rating + Overall",
"kavita-rating-title": "Your Rating",
"close": "{{common.close}}"
"close": "{{common.close}}",
"critic": "{{review-card.critic}}"
},
"badge-expander": {