diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index 1421cf164..d790e8ede 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -28,13 +28,16 @@ public class ChapterController : BaseApiController private readonly ILocalizationService _localizationService; private readonly IEventHub _eventHub; private readonly ILogger _logger; + private readonly IRatingService _ratingService; - public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger) + public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger logger, + IRatingService ratingService) { _unitOfWork = unitOfWork; _localizationService = localizationService; _eventHub = eventHub; _logger = logger; + _ratingService = ratingService; } /// @@ -403,4 +406,15 @@ public class ChapterController : BaseApiController return await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId()); } + [HttpPost("update-rating")] + public async Task UpdateRating(UpdateChapterRatingDto dto) + { + if (await _ratingService.UpdateChapterRating(User.GetUserId(), dto)) + { + return Ok(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error")); + } + } diff --git a/API/Controllers/RatingController.cs b/API/Controllers/RatingController.cs index a40b6680b..57b448027 100644 --- a/API/Controllers/RatingController.cs +++ b/API/Controllers/RatingController.cs @@ -38,4 +38,15 @@ public class RatingController : BaseApiController FavoriteCount = 0 }); } + + [HttpGet("overall/chapter")] + public async Task> GetOverallChapterRating([FromQuery] int chapterId) + { + return Ok(new RatingDto + { + Provider = ScrobbleProvider.Kavita, + AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()), + FavoriteCount = 0, + }); + } } diff --git a/API/DTOs/UpdateChapterRatingDto.cs b/API/DTOs/UpdateChapterRatingDto.cs new file mode 100644 index 000000000..df57a5b42 --- /dev/null +++ b/API/DTOs/UpdateChapterRatingDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs; + +public class UpdateChapterRatingDto +{ + public int ChapterId { get; init; } + public float Rating { get; init; } +} diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index af3ca9c4d..9e07c39b1 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -48,6 +48,7 @@ public interface IChapterRepository Task AddChapterModifiers(int userId, ChapterDto chapter); IEnumerable GetChaptersForSeries(int seriesId); Task> GetAllChaptersForSeries(int seriesId); + Task 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 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; + } } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 9872b96f7..68ea0150f 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -66,6 +66,7 @@ public interface IUserRepository Task IsUserAdminAsync(AppUser? user); Task> GetRoles(int userId); Task GetUserRatingAsync(int seriesId, int userId); + Task GetUserChapterRatingAsync(int chapterId, int userId); Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId); Task> GetUserRatingDtosForVolumeAsync(int volumeId, int userId); Task> 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 GetUserChapterRatingAsync(int chapterId, int userId) + { + return await _context.AppUserChapterRating + .Where(r => r.ChapterId == chapterId && r.AppUserId == userId) + .FirstOrDefaultAsync(); + } public async Task> GetUserRatingDtosForSeriesAsync(int seriesId, int userId) { diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 774413e8e..6b91de60b 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -52,6 +52,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Services/RatingService.cs b/API/Services/RatingService.cs new file mode 100644 index 000000000..d327f4a0c --- /dev/null +++ b/API/Services/RatingService.cs @@ -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 UpdateChapterRating(int userId, UpdateChapterRatingDto dto); +} + +public class RatingService: IRatingService +{ + + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + + public RatingService(IUnitOfWork unitOfWork, ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + public async Task 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; + } +} diff --git a/UI/Web/src/app/_services/chapter.service.ts b/UI/Web/src/app/_services/chapter.service.ts index 5adf2a91a..39855536d 100644 --- a/UI/Web/src/app/_services/chapter.service.ts +++ b/UI/Web/src/app/_services/chapter.service.ts @@ -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(this.baseUrl + 'review/chapter/'+chapterId, {seriesId, body}); + return this.httpClient.post(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(this.baseUrl + 'rating/overall?chapterId='+chapterId); + } + + updateRating(chapterId: number, rating: number) { + return this.httpClient.post(this.baseUrl + 'chapter/update-rating', {chapterId, rating}); + } + } diff --git a/UI/Web/src/app/_services/series.service.ts b/UI/Web/src/app/_services/series.service.ts index 3e9a468b6..83c80d0fa 100644 --- a/UI/Web/src/app/_services/series.service.ts +++ b/UI/Web/src/app/_services/series.service.ts @@ -210,7 +210,7 @@ export class SeriesService { } updateReview(seriesId: number, body: string, rating: number) { return this.httpClient.post(this.baseUrl + 'review', { - seriesId, body, rating + seriesId, rating, body }); } 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 b628ffe94..7297c6431 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 @@ -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(); + @Output() refresh = new EventEmitter(); 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); } diff --git a/UI/Web/src/app/_single-module/review-modal/review-modal.component.ts b/UI/Web/src/app/_single-module/review-modal/review-modal.component.ts index d10d418f2..25a7f645d 100644 --- a/UI/Web/src/app/_single-module/review-modal/review-modal.component.ts +++ b/UI/Web/src/app/_single-module/review-modal/review-modal.component.ts @@ -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}); }); } 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 89de77b84..6e1548876 100644 --- a/UI/Web/src/app/_single-module/reviews/reviews.component.ts +++ b/UI/Web/src/app/_single-module/reviews/reviews.component.ts @@ -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; diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index 795e20e53..77c861ad4 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -27,15 +27,17 @@ - - - - - - - - - +
+ @let rating = userRating(); + + +
diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index b035c4b1d..081cf2a7a 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -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> = 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; } diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index f2a1c7f4c..5106741c6 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -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(); diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 77d035751..1fd9007dc 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -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'; 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 3463307a1..3d11eb9e6 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -29,17 +29,18 @@ [mangaFormat]="series.format"> - - - - - - - - - - - + @if (libraryType !== null && series && volume.chapters.length === 1) { +
+ @let rating = userRating(); + +
+ }
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 1f0206d73..77ccc7f6e 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -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> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this)); chapterActions: Array> = 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; }