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 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);
}
/// <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
/// </summary>
public int SeriesId { get; set; }
public int? VolumeId { get; set; }
public int? ChapterId { get; set; }
/// <summary>
/// The library this series belongs in
/// </summary>

View file

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

View file

@ -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))

View file

@ -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<UserReview[]>(this.baseUrl + 'volume/review?volumeId='+volumeId);
}
}

View file

@ -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<ReviewSeriesModalCloseEvent>();
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) {

View file

@ -1,9 +1,9 @@
<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-{{getUserReviews().length > 0 ? 'pen' : 'plus'}}"
[clickableTitle]="true" (sectionClick)="openReviewModal()">
[iconClasses]=iconClasses()
[clickableTitle]="canEditOrAdd()" (sectionClick)="openReviewModal()">
<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>
</app-carousel-reel>
</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() {
const userReview = this.getUserReviews();

View file

@ -191,6 +191,20 @@
</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">
<a ngbNavLink>{{t('details-tab')}}</a>
<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 {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<UserReview> = [];
plusReviews: Array<UserReview> = [];
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);