From e0b27f464f1db071956c309034b99acf8e21d82f Mon Sep 17 00:00:00 2001
From: Amelia <77553571+Fesaa@users.noreply.github.com>
Date: Fri, 25 Apr 2025 22:57:45 +0200
Subject: [PATCH] Display chapter reviews in volume page
---
API/Controllers/VolumeController.cs | 15 +++-
API/DTOs/SeriesDetail/UserReviewDto.cs | 2 +
API/Data/Repositories/UserRepository.cs | 13 ++++
API/Helpers/AutoMapperProfiles.cs | 6 ++
UI/Web/src/app/_services/volume.service.ts | 6 ++
.../review-card/review-card.component.ts | 8 ++-
.../reviews/reviews.component.html | 6 +-
.../reviews/reviews.component.ts | 20 ++++++
.../volume-detail.component.html | 14 ++++
.../volume-detail/volume-detail.component.ts | 70 +++++++++++--------
10 files changed, 124 insertions(+), 36 deletions(-)
diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs
index db1381d9d..0e1f57b0b 100644
--- a/API/Controllers/VolumeController.cs
+++ b/API/Controllers/VolumeController.cs
@@ -1,9 +1,11 @@
-using System.Linq;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
+using API.DTOs.SeriesDetail;
using API.Extensions;
using API.Services;
using API.SignalR;
@@ -81,4 +83,15 @@ public class VolumeController : BaseApiController
return Ok(true);
}
+
+ ///
+ /// Returns all reviews related to this volume, that is, the union of reviews of this volumes chapters
+ ///
+ ///
+ ///
+ [HttpGet("review")]
+ public async Task> VolumeReviews([FromQuery] int volumeId)
+ {
+ return await _unitOfWork.UserRepository.GetUserRatingDtosForVolumeAsync(volumeId, User.GetUserId());
+ }
}
diff --git a/API/DTOs/SeriesDetail/UserReviewDto.cs b/API/DTOs/SeriesDetail/UserReviewDto.cs
index 0e080d43f..8b3d7e4c3 100644
--- a/API/DTOs/SeriesDetail/UserReviewDto.cs
+++ b/API/DTOs/SeriesDetail/UserReviewDto.cs
@@ -26,6 +26,8 @@ public class UserReviewDto
/// The series this is for
///
public int SeriesId { get; set; }
+ public int? VolumeId { get; set; }
+ public int? ChapterId { get; set; }
///
/// The library this series belongs in
///
diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs
index 8efe4093f..9872b96f7 100644
--- a/API/Data/Repositories/UserRepository.cs
+++ b/API/Data/Repositories/UserRepository.cs
@@ -67,6 +67,7 @@ public interface IUserRepository
Task> GetRoles(int userId);
Task GetUserRatingAsync(int seriesId, int userId);
Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
+ Task> GetUserRatingDtosForVolumeAsync(int volumeId, int userId);
Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId);
Task GetPreferencesAsync(string username);
Task> GetBookmarkDtosForSeries(int userId, int seriesId);
@@ -604,6 +605,18 @@ public class UserRepository : IUserRepository
.ProjectTo(_mapper.ConfigurationProvider)
.ToListAsync();
}
+ public async Task> GetUserRatingDtosForVolumeAsync(int volumeId, int userId)
+ {
+ return await _context.AppUserChapterRating
+ .Include(r => r.AppUser)
+ .Where(r => r.VolumeId == volumeId)
+ .Where(r => r.AppUser.UserPreferences.ShareReviews || r.AppUserId == userId)
+ .OrderBy(r => r.AppUserId == userId)
+ .ThenBy(r => r.Rating)
+ .AsSplitQuery()
+ .ProjectTo(_mapper.ConfigurationProvider)
+ .ToListAsync();
+ }
public async Task> GetUserRatingDtosForChapterAsync(int chapterId, int userId)
{
diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs
index 334403ab3..b725834b2 100644
--- a/API/Helpers/AutoMapperProfiles.cs
+++ b/API/Helpers/AutoMapperProfiles.cs
@@ -101,6 +101,12 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.LibraryId,
opt =>
opt.MapFrom(src => src.Series.LibraryId))
+ .ForMember(dest => dest.VolumeId,
+ opt =>
+ opt.MapFrom(src => src.VolumeId))
+ .ForMember(dest => dest.ChapterId,
+ opt =>
+ opt.MapFrom(src => src.ChapterId))
.ForMember(dest => dest.Body,
opt =>
opt.MapFrom(src => src.Review))
diff --git a/UI/Web/src/app/_services/volume.service.ts b/UI/Web/src/app/_services/volume.service.ts
index f53a20543..777f1ed61 100644
--- a/UI/Web/src/app/_services/volume.service.ts
+++ b/UI/Web/src/app/_services/volume.service.ts
@@ -3,6 +3,7 @@ import {environment} from "../../environments/environment";
import { HttpClient } from "@angular/common/http";
import {Volume} from "../_models/volume";
import {TextResonse} from "../_types/text-response";
+import {UserReview} from "../_single-module/review-card/user-review";
@Injectable({
providedIn: 'root'
@@ -28,4 +29,9 @@ export class VolumeService {
updateVolume(volume: any) {
return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse);
}
+
+ volumeReviews(volumeId: number) {
+ return this.httpClient.get(this.baseUrl + 'volume/review?volumeId='+volumeId);
+ }
+
}
diff --git a/UI/Web/src/app/_single-module/review-card/review-card.component.ts b/UI/Web/src/app/_single-module/review-card/review-card.component.ts
index 61967c3b1..f609248fa 100644
--- a/UI/Web/src/app/_single-module/review-card/review-card.component.ts
+++ b/UI/Web/src/app/_single-module/review-card/review-card.component.ts
@@ -35,6 +35,7 @@ export class ReviewCardComponent implements OnInit {
protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) review!: UserReview;
+ @Input() reviewLocation: 'series' | 'chapter' = 'series';
@Output() refresh = new EventEmitter();
isMyReview: boolean = false;
@@ -44,7 +45,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();
}
});
@@ -58,6 +59,11 @@ export class ReviewCardComponent implements OnInit {
component = ReviewCardModalComponent;
}
const ref = this.modalService.open(component, {size: 'lg', fullscreen: 'md'});
+
+ if (this.isMyReview) {
+ ref.componentInstance.reviewLocation = this.reviewLocation;
+ }
+
ref.componentInstance.review = this.review;
ref.closed.subscribe((res: ReviewSeriesModalCloseEvent | undefined) => {
if (res) {
diff --git a/UI/Web/src/app/_single-module/reviews/reviews.component.html b/UI/Web/src/app/_single-module/reviews/reviews.component.html
index cec5deb8d..add498ab9 100644
--- a/UI/Web/src/app/_single-module/reviews/reviews.component.html
+++ b/UI/Web/src/app/_single-module/reviews/reviews.component.html
@@ -1,9 +1,9 @@
+ [iconClasses]=iconClasses()
+ [clickableTitle]="canEditOrAdd()" (sectionClick)="openReviewModal()">
-
+
diff --git a/UI/Web/src/app/_single-module/reviews/reviews.component.ts b/UI/Web/src/app/_single-module/reviews/reviews.component.ts
index 231db4bed..ef72021a4 100644
--- a/UI/Web/src/app/_single-module/reviews/reviews.component.ts
+++ b/UI/Web/src/app/_single-module/reviews/reviews.component.ts
@@ -50,6 +50,26 @@ export class ReviewsComponent {
});
}
+ iconClasses(): string {
+ let classes = 'fa-solid';
+ if (this.canEditOrAdd()) {
+ classes += 'fa-' + (this.getUserReviews().length > 0 ? 'pen' : 'plus');
+ }
+ return classes;
+ }
+
+ canEditOrAdd(): boolean {
+ if (this.reviewLocation === 'series') {
+ return true;
+ }
+
+ if (this.reviewLocation === 'chapter') {
+ return this.chapter !== undefined;
+ }
+
+ return false;
+ }
+
openReviewModal() {
const userReview = this.getUserReviews();
diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html
index e704646fb..bfe75a827 100644
--- a/UI/Web/src/app/volume-detail/volume-detail.component.html
+++ b/UI/Web/src/app/volume-detail/volume-detail.component.html
@@ -191,6 +191,20 @@
}
+
+
+ {{t('reviews-tab')}}
+ {{userReviews.length + plusReviews.length}}
+
+
+ @defer (when activeTabId === TabID.Reviews; prefetch on idle) {
+
+ }
+
+
+
{{t('details-tab')}}
diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts
index 19cf13a27..1f0206d73 100644
--- a/UI/Web/src/app/volume-detail/volume-detail.component.ts
+++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts
@@ -78,6 +78,8 @@ 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";
enum TabID {
@@ -119,36 +121,37 @@ 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
+ ],
templateUrl: './volume-detail.component.html',
styleUrl: './volume-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -196,6 +199,8 @@ export class VolumeDetailComponent implements OnInit {
libraryType: LibraryType | null = null;
activeTabId = TabID.Chapters;
readingLists: ReadingList[] = [];
+ userReviews: Array = [];
+ plusReviews: Array = [];
mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false;
@@ -374,7 +379,8 @@ 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),
+ reviews: this.volumeService.volumeReviews(this.volumeId),
}).subscribe(results => {
if (results.volume === null) {
@@ -385,6 +391,8 @@ export class VolumeDetailComponent implements OnInit {
this.series = results.series;
this.volume = results.volume;
this.libraryType = results.libraryType;
+ this.userReviews = results.reviews.filter(r => !r.isExternal);
+ this.plusReviews = results.reviews.filter(r => r.isExternal);
this.themeService.setColorScape(this.volume!.primaryColor, this.volume!.secondaryColor);