Display chapter reviews in volume page

This commit is contained in:
Amelia 2025-04-25 22:57:45 +02:00
parent 85b6f107bc
commit e0b27f464f
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
10 changed files with 124 additions and 36 deletions

View file

@ -1,9 +1,11 @@
using System.Linq; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Constants; using API.Constants;
using API.Data; using API.Data;
using API.Data.Repositories; using API.Data.Repositories;
using API.DTOs; using API.DTOs;
using API.DTOs.SeriesDetail;
using API.Extensions; using API.Extensions;
using API.Services; using API.Services;
using API.SignalR; using API.SignalR;
@ -81,4 +83,15 @@ public class VolumeController : BaseApiController
return Ok(true); return Ok(true);
} }
/// <summary>
/// Returns all reviews related to this volume, that is, the union of reviews of this volumes chapters
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("review")]
public async Task<IList<UserReviewDto>> VolumeReviews([FromQuery] int volumeId)
{
return await _unitOfWork.UserRepository.GetUserRatingDtosForVolumeAsync(volumeId, User.GetUserId());
}
} }

View file

@ -26,6 +26,8 @@ public class UserReviewDto
/// The series this is for /// The series this is for
/// </summary> /// </summary>
public int SeriesId { get; set; } public int SeriesId { get; set; }
public int? VolumeId { get; set; }
public int? ChapterId { get; set; }
/// <summary> /// <summary>
/// The library this series belongs in /// The library this series belongs in
/// </summary> /// </summary>

View file

@ -67,6 +67,7 @@ public interface IUserRepository
Task<IList<string>> GetRoles(int userId); Task<IList<string>> GetRoles(int userId);
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId); Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId);
Task<IList<UserReviewDto>> GetUserRatingDtosForVolumeAsync(int volumeId, int userId);
Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId); Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId);
Task<AppUserPreferences?> GetPreferencesAsync(string username); Task<AppUserPreferences?> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId); Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
@ -604,6 +605,18 @@ public class UserRepository : IUserRepository
.ProjectTo<UserReviewDto>(_mapper.ConfigurationProvider) .ProjectTo<UserReviewDto>(_mapper.ConfigurationProvider)
.ToListAsync(); .ToListAsync();
} }
public async Task<IList<UserReviewDto>> 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<UserReviewDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId) public async Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId)
{ {

View file

@ -101,6 +101,12 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.LibraryId, .ForMember(dest => dest.LibraryId,
opt => opt =>
opt.MapFrom(src => src.Series.LibraryId)) 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, .ForMember(dest => dest.Body,
opt => opt =>
opt.MapFrom(src => src.Review)) opt.MapFrom(src => src.Review))

View file

@ -3,6 +3,7 @@ import {environment} from "../../environments/environment";
import { HttpClient } from "@angular/common/http"; import { HttpClient } from "@angular/common/http";
import {Volume} from "../_models/volume"; import {Volume} from "../_models/volume";
import {TextResonse} from "../_types/text-response"; import {TextResonse} from "../_types/text-response";
import {UserReview} from "../_single-module/review-card/user-review";
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -28,4 +29,9 @@ export class VolumeService {
updateVolume(volume: any) { updateVolume(volume: any) {
return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse); return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse);
} }
volumeReviews(volumeId: number) {
return this.httpClient.get<UserReview[]>(this.baseUrl + 'volume/review?volumeId='+volumeId);
}
} }

View file

@ -35,6 +35,7 @@ export class ReviewCardComponent implements OnInit {
protected readonly ScrobbleProvider = ScrobbleProvider; protected readonly ScrobbleProvider = ScrobbleProvider;
@Input({required: true}) review!: UserReview; @Input({required: true}) review!: UserReview;
@Input() reviewLocation: 'series' | 'chapter' = 'series';
@Output() refresh = new EventEmitter<ReviewSeriesModalCloseEvent>(); @Output() refresh = new EventEmitter<ReviewSeriesModalCloseEvent>();
isMyReview: boolean = false; isMyReview: boolean = false;
@ -44,7 +45,7 @@ export class ReviewCardComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.accountService.currentUser$.subscribe(u => { this.accountService.currentUser$.subscribe(u => {
if (u) { if (u) {
this.isMyReview = this.review.username === u.username; this.isMyReview = this.review.username === u.username && !this.review.isExternal;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
}); });
@ -58,6 +59,11 @@ export class ReviewCardComponent implements OnInit {
component = ReviewCardModalComponent; component = ReviewCardModalComponent;
} }
const ref = this.modalService.open(component, {size: 'lg', fullscreen: 'md'}); const ref = this.modalService.open(component, {size: 'lg', fullscreen: 'md'});
if (this.isMyReview) {
ref.componentInstance.reviewLocation = this.reviewLocation;
}
ref.componentInstance.review = this.review; ref.componentInstance.review = this.review;
ref.closed.subscribe((res: ReviewSeriesModalCloseEvent | undefined) => { ref.closed.subscribe((res: ReviewSeriesModalCloseEvent | undefined) => {
if (res) { if (res) {

View file

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

View file

@ -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() { openReviewModal() {
const userReview = this.getUserReviews(); const userReview = this.getUserReviews();

View file

@ -191,6 +191,20 @@
</li> </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" [volumeId]="volumeId"
reviewLocation="chapter" />
}
</ng-template>
</li>
<li [ngbNavItem]="TabID.Details" id="details-tab"> <li [ngbNavItem]="TabID.Details" id="details-tab">
<a ngbNavLink>{{t('details-tab')}}</a> <a ngbNavLink>{{t('details-tab')}}</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>

View file

@ -78,6 +78,8 @@ import {EditChapterModalComponent} from "../_single-module/edit-chapter-modal/ed
import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component"; import {BulkOperationsComponent} from "../cards/bulk-operations/bulk-operations.component";
import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component"; import {CoverImageComponent} from "../_single-module/cover-image/cover-image.component";
import {DefaultModalOptions} from "../_models/default-modal-options"; 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 { enum TabID {
@ -119,36 +121,37 @@ interface VolumeCast extends IHasCast {
@Component({ @Component({
selector: 'app-volume-detail', selector: 'app-volume-detail',
imports: [ imports: [
LoadingComponent, LoadingComponent,
NgbNavOutlet, NgbNavOutlet,
DetailsTabComponent, DetailsTabComponent,
NgbNavItem, NgbNavItem,
NgbNavLink, NgbNavLink,
NgbNavContent, NgbNavContent,
NgbNav, NgbNav,
ReadMoreComponent, ReadMoreComponent,
AsyncPipe, AsyncPipe,
NgbDropdownItem, NgbDropdownItem,
NgbDropdownMenu, NgbDropdownMenu,
NgbDropdown, NgbDropdown,
NgbDropdownToggle, NgbDropdownToggle,
EntityTitleComponent, EntityTitleComponent,
RouterLink, RouterLink,
NgbTooltip, NgbTooltip,
NgStyle, NgStyle,
NgClass, NgClass,
TranslocoDirective, TranslocoDirective,
VirtualScrollerModule, VirtualScrollerModule,
ChapterCardComponent, ChapterCardComponent,
RelatedTabComponent, RelatedTabComponent,
BadgeExpanderComponent, BadgeExpanderComponent,
MetadataDetailRowComponent, MetadataDetailRowComponent,
DownloadButtonComponent, DownloadButtonComponent,
CardActionablesComponent, CardActionablesComponent,
BulkOperationsComponent, BulkOperationsComponent,
CoverImageComponent CoverImageComponent,
], ReviewsComponent
],
templateUrl: './volume-detail.component.html', templateUrl: './volume-detail.component.html',
styleUrl: './volume-detail.component.scss', styleUrl: './volume-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@ -196,6 +199,8 @@ export class VolumeDetailComponent implements OnInit {
libraryType: LibraryType | null = null; libraryType: LibraryType | null = null;
activeTabId = TabID.Chapters; activeTabId = TabID.Chapters;
readingLists: ReadingList[] = []; readingLists: ReadingList[] = [];
userReviews: Array<UserReview> = [];
plusReviews: Array<UserReview> = [];
mobileSeriesImgBackground: string | undefined; mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false; downloadInProgress: boolean = false;
@ -374,7 +379,8 @@ export class VolumeDetailComponent implements OnInit {
forkJoin({ forkJoin({
series: this.seriesService.getSeries(this.seriesId), series: this.seriesService.getSeries(this.seriesId),
volume: this.volumeService.getVolumeMetadata(this.volumeId), 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 => { }).subscribe(results => {
if (results.volume === null) { if (results.volume === null) {
@ -385,6 +391,8 @@ export class VolumeDetailComponent implements OnInit {
this.series = results.series; this.series = results.series;
this.volume = results.volume; this.volume = results.volume;
this.libraryType = results.libraryType; 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); this.themeService.setColorScape(this.volume!.primaryColor, this.volume!.secondaryColor);