Add rating to chapter page & volume (if one chapter)

This commit is contained in:
Amelia 2025-04-26 09:40:56 +02:00
parent e96cb0fde9
commit 8ccc2b5801
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
18 changed files with 226 additions and 47 deletions

View file

@ -28,13 +28,16 @@ public class ChapterController : BaseApiController
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
private readonly ILogger<ChapterController> _logger;
private readonly IRatingService _ratingService;
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger,
IRatingService ratingService)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_eventHub = eventHub;
_logger = logger;
_ratingService = ratingService;
}
/// <summary>
@ -403,4 +406,15 @@ public class ChapterController : BaseApiController
return await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId());
}
[HttpPost("update-rating")]
public async Task<ActionResult> UpdateRating(UpdateChapterRatingDto dto)
{
if (await _ratingService.UpdateChapterRating(User.GetUserId(), dto))
{
return Ok();
}
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
}
}

View file

@ -38,4 +38,15 @@ public class RatingController : BaseApiController
FavoriteCount = 0
});
}
[HttpGet("overall/chapter")]
public async Task<ActionResult<RatingDto>> GetOverallChapterRating([FromQuery] int chapterId)
{
return Ok(new RatingDto
{
Provider = ScrobbleProvider.Kavita,
AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()),
FavoriteCount = 0,
});
}
}

View file

@ -0,0 +1,7 @@
namespace API.DTOs;
public class UpdateChapterRatingDto
{
public int ChapterId { get; init; }
public float Rating { get; init; }
}

View file

@ -48,6 +48,7 @@ public interface IChapterRepository
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
IEnumerable<Chapter> GetChaptersForSeries(int seriesId);
Task<IList<Chapter>> GetAllChaptersForSeries(int seriesId);
Task<int> GetAverageUserRating(int chapterId, int userId);
}
public class ChapterRepository : IChapterRepository
{
@ -310,4 +311,20 @@ public class ChapterRepository : IChapterRepository
.ThenInclude(cp => cp.Person)
.ToListAsync();
}
public async Task<int> GetAverageUserRating(int chapterId, int userId)
{
// If there is 0 or 1 rating and that rating is you, return 0 back
var countOfRatingsThatAreUser = await _context.AppUserChapterRating
.Where(r => r.ChapterId == chapterId && r.HasBeenRated)
.CountAsync(u => u.AppUserId == userId);
if (countOfRatingsThatAreUser == 1)
{
return 0;
}
var avg = (await _context.AppUserChapterRating
.Where(r => r.ChapterId == chapterId && r.HasBeenRated)
.AverageAsync(r => (int?) r.Rating));
return avg.HasValue ? (int) (avg.Value * 20) : 0;
}
}

View file

@ -66,6 +66,7 @@ public interface IUserRepository
Task<bool> IsUserAdminAsync(AppUser? user);
Task<IList<string>> GetRoles(int userId);
Task<AppUserRating?> GetUserRatingAsync(int seriesId, int userId);
Task<AppUserChapterRating?> GetUserChapterRatingAsync(int chapterId, 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);
@ -592,6 +593,12 @@ public class UserRepository : IUserRepository
.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
.SingleOrDefaultAsync();
}
public async Task<AppUserChapterRating?> GetUserChapterRatingAsync(int chapterId, int userId)
{
return await _context.AppUserChapterRating
.Where(r => r.ChapterId == chapterId && r.AppUserId == userId)
.FirstOrDefaultAsync();
}
public async Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId)
{

View file

@ -52,6 +52,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IMediaErrorService, MediaErrorService>();
services.AddScoped<IMediaConversionService, MediaConversionService>();
services.AddScoped<IStreamService, StreamService>();
services.AddScoped<IRatingService, RatingService>();
services.AddScoped<IScannerService, ScannerService>();
services.AddScoped<IProcessSeries, ProcessSeries>();

View file

@ -0,0 +1,63 @@
using System;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.Entities;
using Microsoft.Extensions.Logging;
namespace API.Services;
public interface IRatingService
{
Task<bool> UpdateChapterRating(int userId, UpdateChapterRatingDto dto);
}
public class RatingService: IRatingService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<RatingService> _logger;
public RatingService(IUnitOfWork unitOfWork, ILogger<RatingService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<bool> UpdateChapterRating(int userId, UpdateChapterRatingDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ChapterRatings);
if (user == null) throw new UnauthorizedAccessException();
var rating = await _unitOfWork.UserRepository.GetUserChapterRatingAsync(dto.ChapterId, userId) ?? new AppUserChapterRating();
rating.Rating = Math.Clamp(dto.Rating, 0, 5);
rating.HasBeenRated = true;
rating.ChapterId = dto.ChapterId;
if (rating.Id == 0)
{
user.ChapterRatings.Add(rating);
}
_unitOfWork.UserRepository.Update(user);
try
{
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
{
// Scrobble Update?
return true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception while updating chapter rating");
}
await _unitOfWork.RollbackAsync();
user.ChapterRatings.Remove(rating);
return false;
}
}

View file

@ -4,6 +4,7 @@ import { HttpClient } from "@angular/common/http";
import {Chapter} from "../_models/chapter";
import {TextResonse} from "../_types/text-response";
import {UserReview} from "../_single-module/review-card/user-review";
import {Rating} from "../_models/rating";
@Injectable({
providedIn: 'root'
@ -35,11 +36,19 @@ export class ChapterService {
}
updateChapterReview(seriesId: number, chapterId: number, body: string, rating: number) {
return this.httpClient.post<UserReview>(this.baseUrl + 'review/chapter/'+chapterId, {seriesId, body});
return this.httpClient.post<UserReview>(this.baseUrl + 'review/chapter/'+chapterId, {seriesId, rating, body});
}
deleteChapterReview(chapterId: number) {
return this.httpClient.delete(this.baseUrl + 'review/chapter/'+chapterId);
}
overallRating(chapterId: number) {
return this.httpClient.get<Rating>(this.baseUrl + 'rating/overall?chapterId='+chapterId);
}
updateRating(chapterId: number, rating: number) {
return this.httpClient.post(this.baseUrl + 'chapter/update-rating', {chapterId, rating});
}
}

View file

@ -210,7 +210,7 @@ export class SeriesService {
}
updateReview(seriesId: number, body: string, rating: number) {
return this.httpClient.post<UserReview>(this.baseUrl + 'review', {
seriesId, body, rating
seriesId, rating, body
});
}

View file

@ -14,7 +14,7 @@ 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,
ReviewModalCloseEvent,
ReviewModalComponent
} from "../review-modal/review-modal.component";
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
@ -35,7 +35,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;
@ -60,7 +60,7 @@ export class ReviewCardComponent implements OnInit {
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);
}

View file

@ -11,16 +11,16 @@ import {of} from "rxjs";
import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../_services/theme.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({
@ -43,6 +43,7 @@ export class ReviewModalComponent implements OnInit {
@Input({required: true}) review!: UserReview;
reviewGroup!: FormGroup;
rating: number = 0;
starColor = this.themeService.getCssVariable('--rating-star-color');
@ -50,15 +51,16 @@ export class ReviewModalComponent implements OnInit {
this.reviewGroup = new FormGroup({
reviewBody: new FormControl(this.review.body, [Validators.required, Validators.minLength(this.minLength)]),
});
this.rating = this.review.rating;
this.cdRef.markForCheck();
}
updateRating($event: number) {
this.review.rating = $event;
this.rating = $event;
}
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() {
@ -73,7 +75,7 @@ export class ReviewModalComponent implements OnInit {
obs?.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});
});
}
@ -85,13 +87,13 @@ export class ReviewModalComponent implements OnInit {
let obs;
if (!this.review.chapterId) {
obs = this.seriesService.updateReview(this.review.seriesId, model.reviewBody, this.review.rating);
obs = this.seriesService.updateReview(this.review.seriesId, model.reviewBody, this.rating);
} else {
obs = this.chapterService.updateChapterReview(this.review.seriesId, this.review.chapterId, model.reviewBody, this.review.rating);
obs = this.chapterService.updateChapterReview(this.review.seriesId, this.review.chapterId, model.reviewBody, this.rating);
}
obs?.subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit});
this.modal.close({success: true, review: review, action: ReviewModalCloseAction.Edit});
});
}

View file

@ -6,8 +6,8 @@ import {UserReview} from "../review-card/user-review";
import {User} from "../../_models/user";
import {AccountService} from "../../_services/account.service";
import {
ReviewModalComponent, ReviewSeriesModalCloseAction,
ReviewSeriesModalCloseEvent
ReviewModalComponent, ReviewModalCloseAction,
ReviewModalCloseEvent
} from "../review-modal/review-modal.component";
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
@ -71,11 +71,11 @@ export class ReviewsComponent {
}
updateOrDeleteReview(closeResult: ReviewSeriesModalCloseEvent) {
if (closeResult.action === ReviewSeriesModalCloseAction.Close) return;
updateOrDeleteReview(closeResult: ReviewModalCloseEvent) {
if (closeResult.action === ReviewModalCloseAction.Close) return;
const index = this.userReviews.findIndex(r => r.username === closeResult.review!.username);
if (closeResult.action === ReviewSeriesModalCloseAction.Edit) {
if (closeResult.action === ReviewModalCloseAction.Edit) {
if (index === -1 ) {
this.userReviews = [closeResult.review, ...this.userReviews];
this.cdRef.markForCheck();
@ -86,7 +86,7 @@ export class ReviewsComponent {
return;
}
if (closeResult.action === ReviewSeriesModalCloseAction.Delete) {
if (closeResult.action === ReviewModalCloseAction.Delete) {
this.userReviews = [...this.userReviews.filter(r => r.username !== closeResult.review!.username)];
this.cdRef.markForCheck();
return;

View file

@ -27,15 +27,17 @@
<!-- 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">
@let rating = userRating();
<app-external-rating [seriesId]="series.id"
[ratings]="[]"
[userRating]="rating?.rating || 0"
[hasUserRated]="rating !== undefined"
[libraryType]="libraryType!"
[chapterId]="chapterId"
>
</app-external-rating>
</div>
<div class="mt-3 mb-3">
<div class="row g-0">

View file

@ -71,6 +71,8 @@ import {ReviewCardComponent} from "../_single-module/review-card/review-card.com
import {User} from "../_models/user";
import {ReviewModalComponent} from "../_single-module/review-modal/review-modal.component";
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',
@ -111,7 +113,8 @@ enum TabID {
CoverImageComponent,
CarouselReelComponent,
ReviewCardComponent,
ReviewsComponent
ReviewsComponent,
ExternalRatingComponent
],
templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss',
@ -174,6 +177,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)';
@ -188,6 +192,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');
@ -376,6 +386,10 @@ export class ChapterDetailComponent implements OnInit {
}
}
userRating() {
return this.userReviews.find(r => r.username == this.user?.username && !r.isExternal)
}
protected readonly LibraryType = LibraryType;
protected readonly encodeURIComponent = encodeURIComponent;
}

View file

@ -24,6 +24,7 @@ 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 {ChapterService} from "../../../_services/chapter.service";
@Component({
selector: 'app-external-rating',
@ -38,6 +39,7 @@ export class ExternalRatingComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly seriesService = inject(SeriesService);
private readonly chapterService = inject(ChapterService);
private readonly themeService = inject(ThemeService);
public readonly utilityService = inject(UtilityService);
public readonly destroyRef = inject(DestroyRef);
@ -47,6 +49,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 +61,24 @@ export class ExternalRatingComponent implements OnInit {
starColor = this.themeService.getCssVariable('--rating-star-color');
ngOnInit() {
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
let obs;
if (this.chapterId) {
obs = this.chapterService.overallRating(this.chapterId);
} else {
obs = this.seriesService.getOverallRating(this.seriesId);
}
obs?.subscribe(r => this.overallRating = r.averageScore);
}
updateRating(rating: number) {
this.seriesService.updateRating(this.seriesId, rating).subscribe(() => {
let obs;
if (this.chapterId) {
obs = this.chapterService.updateRating(this.chapterId, rating);
} else {
obs = this.seriesService.updateRating(this.seriesId, rating);
}
obs?.subscribe(() => {
this.userRating = rating;
this.hasUserRated = true;
this.cdRef.markForCheck();

View file

@ -62,8 +62,8 @@ 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,
ReviewModalCloseAction,
ReviewModalCloseEvent,
ReviewModalComponent
} from '../../../_single-module/review-modal/review-modal.component';
import {PageLayoutMode} from 'src/app/_models/page-layout-mode';

View file

@ -29,17 +29,18 @@
[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">
@let rating = userRating();
<app-external-rating [seriesId]="series.id"
[ratings]="[]"
[userRating]="rating?.rating || 0"
[hasUserRated]="rating !== undefined"
[libraryType]="libraryType"
[chapterId]="volume.chapters[0].id"
/>
</div>
}
<div class="mt-2 mb-3">
<div class="row g-0">

View file

@ -80,6 +80,8 @@ import {CoverImageComponent} from "../_single-module/cover-image/cover-image.com
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 {User} from "../_models/user";
enum TabID {
@ -150,7 +152,8 @@ interface VolumeCast extends IHasCast {
CardActionablesComponent,
BulkOperationsComponent,
CoverImageComponent,
ReviewsComponent
ReviewsComponent,
ExternalRatingComponent
],
templateUrl: './volume-detail.component.html',
styleUrl: './volume-detail.component.scss',
@ -204,6 +207,8 @@ export class VolumeDetailComponent implements OnInit {
mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false;
user: User | undefined;
volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this));
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
@ -337,6 +342,12 @@ export class VolumeDetailComponent 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 volumeId = this.route.snapshot.paramMap.get('volumeId');
@ -675,5 +686,9 @@ export class VolumeDetailComponent implements OnInit {
}
}
userRating() {
return this.userReviews.find(r => r.username === this.user?.username && !r.isExternal);
}
protected readonly encodeURIComponent = encodeURIComponent;
}