Load, save, and delete chapter reviews

This commit is contained in:
Amelia 2025-04-25 22:38:32 +02:00
parent a3e04f3bc1
commit 85b6f107bc
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
13 changed files with 192 additions and 13 deletions

View file

@ -6,6 +6,7 @@ using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
@ -391,6 +392,15 @@ public class ChapterController : BaseApiController
return Ok();
}
/// <summary>
/// Get all reviews for this chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("review")]
public async Task<IList<UserReviewDto>> ChapterReviews([FromQuery] int chapterId)
{
return await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId());
}
}

View file

@ -62,6 +62,45 @@ public class ReviewController : BaseApiController
return Ok(_mapper.Map<UserReviewDto>(rating));
}
/// <summary>
/// Updates the review for a given series
/// </summary>
/// <param name="dto"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpPost("chapter/{chapterId}")]
public async Task<ActionResult<UserReviewDto>> UpdateChapterReview(int chapterId, UpdateUserReviewDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings);
if (user == null) return Unauthorized();
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId, ChapterIncludes.None);
if (chapter == null) return BadRequest();
var builder = new ChapterRatingBuilder(user.ChapterRatings.FirstOrDefault(r => r.SeriesId == dto.SeriesId));
var rating = builder
.WithSeriesId(dto.SeriesId)
.WithVolumeId(chapter.VolumeId)
.WithChapterId(chapter.Id)
.WithReview(dto.Body)
.Build();
if (rating.Id == 0)
{
user.ChapterRatings.Add(rating);
}
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
// Do I need this?
//BackgroundJob.Enqueue(() =>
// _scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body));
return Ok(_mapper.Map<UserReviewDto>(rating));
}
/// <summary>
/// Deletes the user's review for the given series
/// </summary>
@ -80,4 +119,24 @@ public class ReviewController : BaseApiController
return Ok();
}
/// <summary>
/// Deletes the user's review for a given chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpDelete("chapter/{chapterId}")]
public async Task<IActionResult> DeleteChapterReview(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings);
if (user == null) return Unauthorized();
user.ChapterRatings = user.ChapterRatings.Where(c => c.ChapterId != chapterId).ToList();
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
return Ok();
}
}

View file

@ -40,6 +40,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<MangaFile> MangaFile { get; set; } = null!;
public DbSet<AppUserProgress> AppUserProgresses { get; set; } = null!;
public DbSet<AppUserRating> AppUserRating { get; set; } = null!;
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
public DbSet<ServerSetting> ServerSetting { get; set; } = null!;
public DbSet<AppUserPreferences> AppUserPreferences { get; set; } = null!;
public DbSet<SeriesMetadata> SeriesMetadata { get; set; } = null!;

View file

@ -42,7 +42,8 @@ public enum AppUserIncludes
DashboardStreams = 2048,
SideNavStreams = 4096,
ExternalSources = 8192,
Collections = 16384 // 2^14
Collections = 16384, // 2^14
ChapterRatings = 1 << 15,
}
public interface IUserRepository
@ -66,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>> GetUserRatingDtosForChapterAsync(int chapterId, int userId);
Task<AppUserPreferences?> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
@ -603,6 +605,19 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<UserReviewDto>> GetUserRatingDtosForChapterAsync(int chapterId, int userId)
{
return await _context.AppUserChapterRating
.Include(r => r.AppUser)
.Where(r => r.ChapterId == chapterId)
.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<AppUserPreferences?> GetPreferencesAsync(string username)
{
return await _context.AppUserPreferences

View file

@ -253,6 +253,11 @@ public static class IncludesExtensions
.ThenInclude(c => c.Items);
}
if (includeFlags.HasFlag(AppUserIncludes.ChapterRatings))
{
query = query.Include(u => u.ChapterRatings);
}
return query.AsSplitQuery();
}

View file

@ -97,6 +97,16 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Username,
opt =>
opt.MapFrom(src => src.AppUser.UserName));
CreateMap<AppUserChapterRating, UserReviewDto>()
.ForMember(dest => dest.LibraryId,
opt =>
opt.MapFrom(src => src.Series.LibraryId))
.ForMember(dest => dest.Body,
opt =>
opt.MapFrom(src => src.Review))
.ForMember(dest => dest.Username,
opt =>
opt.MapFrom(src => src.AppUser.UserName));
CreateMap<AppUserProgress, ProgressDto>()
.ForMember(dest => dest.PageNum,

View file

@ -0,0 +1,50 @@
using System;
using API.Entities;
namespace API.Helpers.Builders;
#nullable enable
public class ChapterRatingBuilder
{
private readonly AppUserChapterRating _rating;
public AppUserChapterRating Build() => _rating;
public ChapterRatingBuilder(AppUserChapterRating? rating = null)
{
_rating = rating ?? new AppUserChapterRating();
}
public ChapterRatingBuilder WithSeriesId(int seriesId)
{
_rating.SeriesId = seriesId;
return this;
}
public ChapterRatingBuilder WithChapterId(int chapterId)
{
_rating.ChapterId = chapterId;
return this;
}
public ChapterRatingBuilder WithVolumeId(int volumeId)
{
_rating.VolumeId = volumeId;
return this;
}
public ChapterRatingBuilder WithRating(int rating)
{
_rating.Rating = Math.Clamp(rating, 0, 5);
_rating.HasBeenRated = true;
return this;
}
public ChapterRatingBuilder WithReview(string review)
{
_rating.Review = review;
return this;
}
}

View file

@ -3,6 +3,7 @@ import {environment} from "../../environments/environment";
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";
@Injectable({
providedIn: 'root'
@ -29,4 +30,16 @@ export class ChapterService {
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
}
chapterReviews(chapterId: number) {
return this.httpClient.get<Array<UserReview>>(this.baseUrl + 'chapter/review?chapterId='+chapterId);
}
updateChapterReview(seriesId: number, chapterId: number, body: string) {
return this.httpClient.post<UserReview>(this.baseUrl + 'review/chapter/'+chapterId, {seriesId, body});
}
deleteChapterReview(chapterId: number) {
return this.httpClient.delete(this.baseUrl + 'review/chapter/'+chapterId);
}
}

View file

@ -1,4 +1,4 @@
<ng-container *transloco="let t; read:'review-series-modal'">
<ng-container *transloco="let t; read:'review-modal'">
<div>
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>

View file

@ -7,6 +7,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ConfirmService} from "../../shared/confirm.service";
import {ToastrService} from "ngx-toastr";
import {ChapterService} from "../../_services/chapter.service";
import {of} from "rxjs";
export enum ReviewSeriesModalCloseAction {
Create,
@ -55,12 +56,18 @@ export class ReviewModalComponent implements OnInit {
async delete() {
if (!await this.confirmService.confirm(translate('toasts.delete-review'))) return;
let obs;
if (this.reviewLocation === 'series') {
this.seriesService.deleteReview(this.review.seriesId).subscribe(() => {
this.toastr.success(translate('toasts.review-deleted'));
this.modal.close({success: true, review: this.review, action: ReviewSeriesModalCloseAction.Delete});
});
obs = this.seriesService.deleteReview(this.review.seriesId);
}
if (this.reviewLocation === 'chapter') {
obs = this.chapterService.deleteChapterReview(this.review.chapterId!)
}
obs?.subscribe(() => {
this.toastr.success(translate('toasts.review-deleted'));
this.modal.close({success: true, review: this.review, action: ReviewSeriesModalCloseAction.Delete});
});
}
save() {
@ -69,11 +76,17 @@ export class ReviewModalComponent implements OnInit {
return;
}
let obs;
if (this.reviewLocation === 'series') {
this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit});
});
obs = this.seriesService.updateReview(this.review.seriesId, model.reviewBody);
}
if (this.reviewLocation === 'chapter') {
obs = this.chapterService.updateChapterReview(this.review.seriesId, this.review.chapterId!, model.reviewBody);
}
obs?.subscribe(review => {
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit});
});
}
}

View file

@ -177,7 +177,7 @@
<li [ngbNavItem]="TabID.Reviews">
<a ngbNavLink>
{{t(TabID.Reviews)}}
{{t('reviews-tab')}}
<span class="badge rounded-pill text-bg-secondary">{{userReviews.length + plusReviews.length}}</span>
</a>
<ng-template ngbNavContent>

View file

@ -222,7 +222,8 @@ export class ChapterDetailComponent implements OnInit {
forkJoin({
series: this.seriesService.getSeries(this.seriesId),
chapter: this.chapterService.getChapterMetadata(this.chapterId),
libraryType: this.libraryService.getLibraryType(this.libraryId)
libraryType: this.libraryService.getLibraryType(this.libraryId),
reviews: this.chapterService.chapterReviews(this.chapterId),
}).subscribe(results => {
if (results.chapter === null) {
@ -234,6 +235,8 @@ export class ChapterDetailComponent implements OnInit {
this.chapter = results.chapter;
this.weblinks = this.chapter.webLinks.split(',');
this.libraryType = results.libraryType;
this.userReviews = results.reviews.filter(r => !r.isExternal);
this.plusReviews = results.reviews.filter(r => r.isExternal);
this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor);

View file

@ -72,7 +72,7 @@
"user-reviews-plus": "External Reviews"
},
"review-series-modal": {
"review-modal": {
"title": "Edit Review",
"review-label": "Review",
"close": "{{common.close}}",