mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Chapter/Issue level Reviews and Ratings (#3778)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
3b8997e46e
commit
4f7625ea77
@ -929,17 +929,13 @@ public class SeriesFilterTests : AbstractDbTest
|
||||
_context.Library.Add(library);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
|
||||
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
|
||||
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>(),
|
||||
Substitute.For<IReadingListService>());
|
||||
var ratingService = new RatingService(_unitOfWork, Substitute.For<IScrobblingService>(), Substitute.For<ILogger<RatingService>>());
|
||||
|
||||
// Select 0 Rating
|
||||
var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
|
||||
Assert.NotNull(zeroRating);
|
||||
|
||||
Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto()
|
||||
{
|
||||
SeriesId = zeroRating.Id,
|
||||
UserRating = 0
|
||||
@ -948,7 +944,7 @@ public class SeriesFilterTests : AbstractDbTest
|
||||
// Select 4.5 Rating
|
||||
var partialRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(3);
|
||||
|
||||
Assert.True(await seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
|
||||
Assert.True(await ratingService.UpdateSeriesRating(user, new UpdateRatingDto()
|
||||
{
|
||||
SeriesId = partialRating.Id,
|
||||
UserRating = 4.5f
|
||||
|
189
API.Tests/Services/RatingServiceTests.cs
Normal file
189
API.Tests/Services/RatingServiceTests.cs
Normal file
@ -0,0 +1,189 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using Hangfire;
|
||||
using Hangfire.InMemory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class RatingServiceTests: AbstractDbTest
|
||||
{
|
||||
private readonly RatingService _ratingService;
|
||||
|
||||
public RatingServiceTests()
|
||||
{
|
||||
_ratingService = new RatingService(_unitOfWork, Substitute.For<IScrobblingService>(), Substitute.For<ILogger<RatingService>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldSetRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
JobStorage.Current = new InMemoryStorage();
|
||||
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))!
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldUpdateExistingRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
JobStorage.Current = new InMemoryStorage();
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
|
||||
// Update the DB again
|
||||
|
||||
var result2 = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 5,
|
||||
});
|
||||
|
||||
Assert.True(result2);
|
||||
|
||||
var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings2);
|
||||
Assert.True(ratings2.Count == 1);
|
||||
Assert.Equal(5, ratings2.First().Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldClampRatingAt5()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 10,
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
JobStorage.Current = new InMemoryStorage();
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007",
|
||||
AppUserIncludes.Ratings)!)
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(5, ratings.First().Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book)
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _ratingService.UpdateSeriesRating(user, new UpdateRatingDto
|
||||
{
|
||||
SeriesId = 2,
|
||||
UserRating = 5,
|
||||
});
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var ratings = user.Ratings;
|
||||
Assert.Empty(ratings);
|
||||
}
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
_context.Series.RemoveRange(_context.Series.ToList());
|
||||
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
|
||||
_context.Genre.RemoveRange(_context.Genre.ToList());
|
||||
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
|
||||
_context.Person.RemoveRange(_context.Person.ToList());
|
||||
_context.Library.RemoveRange(_context.Library.ToList());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
@ -590,164 +590,6 @@ public class SeriesServiceTests : AbstractDbTest
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region UpdateRating
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldSetRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
JobStorage.Current = new InMemoryStorage();
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))!
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldUpdateExistingRating()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 3,
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
JobStorage.Current = new InMemoryStorage();
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(3, ratings.First().Rating);
|
||||
|
||||
// Update the DB again
|
||||
|
||||
var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 5,
|
||||
});
|
||||
|
||||
Assert.True(result2);
|
||||
|
||||
var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings2);
|
||||
Assert.True(ratings2.Count == 1);
|
||||
Assert.Equal(5, ratings2.First().Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldClampRatingAt5()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb")
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto
|
||||
{
|
||||
SeriesId = 1,
|
||||
UserRating = 10,
|
||||
});
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
JobStorage.Current = new InMemoryStorage();
|
||||
var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007",
|
||||
AppUserIncludes.Ratings)!)
|
||||
.Ratings;
|
||||
Assert.NotEmpty(ratings);
|
||||
Assert.Equal(5, ratings.First().Rating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist()
|
||||
{
|
||||
await ResetDb();
|
||||
|
||||
_context.Library.Add(new LibraryBuilder("Test LIb", LibraryType.Book)
|
||||
.WithAppUser(new AppUserBuilder("majora2007", string.Empty).Build())
|
||||
.WithSeries(new SeriesBuilder("Test")
|
||||
|
||||
.WithVolume(new VolumeBuilder("1")
|
||||
.WithChapter(new ChapterBuilder("1").WithPages(1).Build())
|
||||
.Build())
|
||||
.Build())
|
||||
.Build());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
|
||||
|
||||
var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto
|
||||
{
|
||||
SeriesId = 2,
|
||||
UserRating = 5,
|
||||
});
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var ratings = user.Ratings;
|
||||
Assert.Empty(ratings);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateSeriesMetadata
|
||||
|
@ -8,6 +8,10 @@ public static class EasyCacheProfiles
|
||||
public const string RevokedJwt = "revokedJWT";
|
||||
public const string Favicon = "favicon";
|
||||
/// <summary>
|
||||
/// Images for Publishers
|
||||
/// </summary>
|
||||
public const string Publisher = "publisherImages";
|
||||
/// <summary>
|
||||
/// If a user's license is valid
|
||||
/// </summary>
|
||||
public const string License = "license";
|
||||
|
@ -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;
|
||||
@ -14,8 +15,10 @@ using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Nager.ArticleNumber;
|
||||
|
||||
@ -27,13 +30,16 @@ public class ChapterController : BaseApiController
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ILogger<ChapterController> _logger;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger)
|
||||
public ChapterController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IEventHub eventHub, ILogger<ChapterController> logger,
|
||||
IMapper mapper)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_localizationService = localizationService;
|
||||
_eventHub = eventHub;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -391,6 +397,39 @@ public class ChapterController : BaseApiController
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Ratings and Reviews for an individual Chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("chapter-detail-plus")]
|
||||
public async Task<ActionResult<ChapterDetailPlusDto>> ChapterDetailPlus([FromQuery] int chapterId)
|
||||
{
|
||||
var ret = new ChapterDetailPlusDto();
|
||||
|
||||
var userReviews = (await _unitOfWork.UserRepository.GetUserRatingDtosForChapterAsync(chapterId, User.GetUserId()))
|
||||
.Where(r => !string.IsNullOrEmpty(r.Body))
|
||||
.OrderByDescending(review => review.Username.Equals(User.GetUsername()) ? 1 : 0)
|
||||
.ToList();
|
||||
|
||||
var ownRating = await _unitOfWork.UserRepository.GetUserChapterRatingAsync(User.GetUserId(), chapterId);
|
||||
if (ownRating != null)
|
||||
{
|
||||
ret.Rating = ownRating.Rating;
|
||||
ret.HasBeenRated = ownRating.HasBeenRated;
|
||||
}
|
||||
|
||||
var externalReviews = await _unitOfWork.ChapterRepository.GetExternalChapterReviews(chapterId);
|
||||
if (externalReviews.Count > 0)
|
||||
{
|
||||
userReviews.AddRange(ReviewHelper.SelectSpectrumOfReviews(externalReviews));
|
||||
}
|
||||
|
||||
ret.Reviews = userReviews;
|
||||
|
||||
ret.Ratings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapterId);
|
||||
|
||||
return Ok(ret);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1,15 +1,12 @@
|
||||
using System;
|
||||
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.Extensions;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using EasyCaching.Core;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
@ -21,21 +18,85 @@ namespace API.Controllers;
|
||||
public class RatingController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IRatingService _ratingService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
|
||||
public RatingController(IUnitOfWork unitOfWork)
|
||||
public RatingController(IUnitOfWork unitOfWork, IRatingService ratingService, ILocalizationService localizationService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
|
||||
_ratingService = ratingService;
|
||||
_localizationService = localizationService;
|
||||
}
|
||||
|
||||
[HttpGet("overall")]
|
||||
public async Task<ActionResult<RatingDto>> GetOverallRating(int seriesId)
|
||||
/// <summary>
|
||||
/// Update the users' rating of the given series
|
||||
/// </summary>
|
||||
/// <param name="updateRating"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="UnauthorizedAccessException"></exception>
|
||||
[HttpPost("series")]
|
||||
public async Task<ActionResult> UpdateSeriesRating(UpdateRatingDto updateRating)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
if (await _ratingService.UpdateSeriesRating(user, updateRating))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the users' rating of the given chapter
|
||||
/// </summary>
|
||||
/// <param name="updateRating">chapterId must be set</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="UnauthorizedAccessException"></exception>
|
||||
[HttpPost("chapter")]
|
||||
public async Task<ActionResult> UpdateChapterRating(UpdateRatingDto updateRating)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings | AppUserIncludes.ChapterRatings);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
if (await _ratingService.UpdateChapterRating(user, updateRating))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall rating from all Kavita users for a given Series
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("overall-series")]
|
||||
public async Task<ActionResult<RatingDto>> GetOverallSeriesRating(int seriesId)
|
||||
{
|
||||
return Ok(new RatingDto()
|
||||
{
|
||||
Provider = ScrobbleProvider.Kavita,
|
||||
AverageScore = await _unitOfWork.SeriesRepository.GetAverageUserRating(seriesId, User.GetUserId()),
|
||||
FavoriteCount = 0
|
||||
FavoriteCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overall rating from all Kavita users for a given Chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("overall-chapter")]
|
||||
public async Task<ActionResult<RatingDto>> GetOverallChapterRating(int chapterId)
|
||||
{
|
||||
return Ok(new RatingDto()
|
||||
{
|
||||
Provider = ScrobbleProvider.Kavita,
|
||||
AverageScore = await _unitOfWork.ChapterRepository.GetAverageUserRating(chapterId, User.GetUserId()),
|
||||
FavoriteCount = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Plus;
|
||||
@ -30,17 +33,17 @@ public class ReviewController : BaseApiController
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Updates the review for a given series
|
||||
/// Updates the user's review for a given series
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserReviewDto>> UpdateReview(UpdateUserReviewDto dto)
|
||||
[HttpPost("series")]
|
||||
public async Task<ActionResult<UserReviewDto>> UpdateSeriesReview(UpdateUserReviewDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var ratingBuilder = new RatingBuilder(user.Ratings.FirstOrDefault(r => r.SeriesId == dto.SeriesId));
|
||||
var ratingBuilder = new RatingBuilder(await _unitOfWork.UserRepository.GetUserRatingAsync(dto.SeriesId, user.Id));
|
||||
|
||||
var rating = ratingBuilder
|
||||
.WithBody(dto.Body)
|
||||
@ -52,22 +55,58 @@ public class ReviewController : BaseApiController
|
||||
{
|
||||
user.Ratings.Add(rating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
|
||||
BackgroundJob.Enqueue(() =>
|
||||
_scrobblingService.ScrobbleReviewUpdate(user.Id, dto.SeriesId, string.Empty, dto.Body));
|
||||
return Ok(_mapper.Map<UserReviewDto>(rating));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the user's review for a given chapter
|
||||
/// </summary>
|
||||
/// <param name="dto">chapterId must be set</param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("chapter")]
|
||||
public async Task<ActionResult<UserReviewDto>> UpdateChapterReview(UpdateUserReviewDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
if (dto.ChapterId == null) return BadRequest();
|
||||
|
||||
int chapterId = dto.ChapterId.Value;
|
||||
|
||||
var ratingBuilder = new ChapterRatingBuilder(await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, chapterId));
|
||||
|
||||
var rating = ratingBuilder
|
||||
.WithBody(dto.Body)
|
||||
.WithSeriesId(dto.SeriesId)
|
||||
.WithChapterId(chapterId)
|
||||
.Build();
|
||||
|
||||
if (rating.Id == 0)
|
||||
{
|
||||
user.ChapterRatings.Add(rating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
return Ok(_mapper.Map<UserReviewDto>(rating));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the user's review for the given series
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult> DeleteReview(int seriesId)
|
||||
[HttpDelete("series")]
|
||||
public async Task<ActionResult> DeleteSeriesReview([FromQuery] int seriesId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.Ratings);
|
||||
if (user == null) return Unauthorized();
|
||||
@ -80,4 +119,23 @@ public class ReviewController : BaseApiController
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the user's review for the given chapter
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpDelete("chapter")]
|
||||
public async Task<ActionResult> DeleteChapterReview([FromQuery] int chapterId)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ChapterRatings);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
user.ChapterRatings = user.ChapterRatings.Where(r => r.ChapterId != chapterId).ToList();
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
@ -191,21 +191,6 @@ public class SeriesController : BaseApiController
|
||||
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Update the user rating for the given series
|
||||
/// </summary>
|
||||
/// <param name="updateSeriesRatingDto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-rating")]
|
||||
public async Task<ActionResult> UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings);
|
||||
if (!await _seriesService.UpdateRating(user!, updateSeriesRatingDto))
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the Series
|
||||
/// </summary>
|
||||
|
14
API/DTOs/ChapterDetailPlusDto.cs
Normal file
14
API/DTOs/ChapterDetailPlusDto.cs
Normal file
@ -0,0 +1,14 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.SeriesDetail;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
public class ChapterDetailPlusDto
|
||||
{
|
||||
public float Rating { get; set; }
|
||||
public bool HasBeenRated { get; set; }
|
||||
|
||||
public IList<UserReviewDto> Reviews { get; set; } = [];
|
||||
public IList<RatingDto> Ratings { get; set; } = [];
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using API.Services.Plus;
|
||||
using API.Entities.Enums;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.DTOs;
|
||||
#nullable enable
|
||||
@ -8,5 +9,6 @@ public class RatingDto
|
||||
public int AverageScore { get; set; }
|
||||
public int FavoriteCount { get; set; }
|
||||
public ScrobbleProvider Provider { get; set; }
|
||||
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
|
||||
public string? ProviderUrl { get; set; }
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
#nullable enable
|
||||
|
||||
public class UpdateUserReviewDto
|
||||
{
|
||||
public int SeriesId { get; set; }
|
||||
public int? ChapterId { get; set; }
|
||||
public string Body { get; set; }
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
using API.Services.Plus;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.DTOs.SeriesDetail;
|
||||
#nullable enable
|
||||
@ -26,6 +28,7 @@ public class UserReviewDto
|
||||
/// The series this is for
|
||||
/// </summary>
|
||||
public int SeriesId { get; set; }
|
||||
public int? ChapterId { get; set; }
|
||||
/// <summary>
|
||||
/// The library this series belongs in
|
||||
/// </summary>
|
||||
@ -54,4 +57,8 @@ public class UserReviewDto
|
||||
/// If this review is External, which Provider did it come from
|
||||
/// </summary>
|
||||
public ScrobbleProvider Provider { get; set; } = ScrobbleProvider.Kavita;
|
||||
/// <summary>
|
||||
/// Source of the Rating
|
||||
/// </summary>
|
||||
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
namespace API.DTOs;
|
||||
|
||||
public class UpdateSeriesRatingDto
|
||||
public class UpdateRatingDto
|
||||
{
|
||||
public int SeriesId { get; init; }
|
||||
public int? ChapterId { get; init; }
|
||||
public float UserRating { get; init; }
|
||||
}
|
@ -78,6 +78,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<EmailHistory> EmailHistory { get; set; } = null!;
|
||||
public DbSet<MetadataSettings> MetadataSettings { get; set; } = null!;
|
||||
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
||||
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
|
3536
API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs
generated
Normal file
3536
API/Data/Migrations/20250429150140_ChapterRatingAndReviews.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
165
API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs
Normal file
165
API/Data/Migrations/20250429150140_ChapterRatingAndReviews.cs
Normal file
@ -0,0 +1,165 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ChapterRatingAndReviews : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Authority",
|
||||
table: "ExternalReview",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ChapterId",
|
||||
table: "ExternalReview",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Authority",
|
||||
table: "ExternalRating",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ChapterId",
|
||||
table: "ExternalRating",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<float>(
|
||||
name: "AverageExternalRating",
|
||||
table: "Chapter",
|
||||
type: "REAL",
|
||||
nullable: false,
|
||||
defaultValue: 0f);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserChapterRating",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Rating = table.Column<float>(type: "REAL", nullable: false),
|
||||
HasBeenRated = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Review = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserChapterRating", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserChapterRating_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserChapterRating_Chapter_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapter",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserChapterRating_Series_SeriesId",
|
||||
column: x => x.SeriesId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalReview_ChapterId",
|
||||
table: "ExternalReview",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalRating_ChapterId",
|
||||
table: "ExternalRating",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserChapterRating_AppUserId",
|
||||
table: "AppUserChapterRating",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserChapterRating_ChapterId",
|
||||
table: "AppUserChapterRating",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserChapterRating_SeriesId",
|
||||
table: "AppUserChapterRating",
|
||||
column: "SeriesId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ExternalRating_Chapter_ChapterId",
|
||||
table: "ExternalRating",
|
||||
column: "ChapterId",
|
||||
principalTable: "Chapter",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ExternalReview_Chapter_ChapterId",
|
||||
table: "ExternalReview",
|
||||
column: "ChapterId",
|
||||
principalTable: "Chapter",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ExternalRating_Chapter_ChapterId",
|
||||
table: "ExternalRating");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ExternalReview_Chapter_ChapterId",
|
||||
table: "ExternalReview");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserChapterRating");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExternalReview_ChapterId",
|
||||
table: "ExternalReview");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExternalRating_ChapterId",
|
||||
table: "ExternalRating");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Authority",
|
||||
table: "ExternalReview");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChapterId",
|
||||
table: "ExternalReview");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Authority",
|
||||
table: "ExternalRating");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ChapterId",
|
||||
table: "ExternalRating");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AverageExternalRating",
|
||||
table: "Chapter");
|
||||
}
|
||||
}
|
||||
}
|
@ -195,6 +195,41 @@ namespace API.Data.Migrations
|
||||
b.ToTable("AppUserBookmark");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("HasBeenRated")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Review")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserChapterRating");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -752,6 +787,9 @@ namespace API.Data.Migrations
|
||||
b.Property<string>("AlternateSeries")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("AverageExternalRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<float>("AvgHoursToRead")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
@ -1316,9 +1354,15 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Authority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AverageScore")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("FavoriteCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -1333,6 +1377,8 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("ExternalRating");
|
||||
});
|
||||
|
||||
@ -1379,12 +1425,18 @@ namespace API.Data.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Authority")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Body")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("BodyJustText")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Provider")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -1414,6 +1466,8 @@ namespace API.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("ExternalReview");
|
||||
});
|
||||
|
||||
@ -2618,6 +2672,33 @@ namespace API.Data.Migrations
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("ChapterRatings")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.Chapter", "Chapter")
|
||||
.WithMany("Ratings")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("API.Entities.Series", "Series")
|
||||
.WithMany()
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserCollection", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
@ -2905,6 +2986,20 @@ namespace API.Data.Migrations
|
||||
b.Navigation("Chapter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Chapter", null)
|
||||
.WithMany("ExternalRatings")
|
||||
.HasForeignKey("ChapterId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Chapter", null)
|
||||
.WithMany("ExternalReviews")
|
||||
.HasForeignKey("ChapterId");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Series", "Series")
|
||||
@ -3332,6 +3427,8 @@ namespace API.Data.Migrations
|
||||
{
|
||||
b.Navigation("Bookmarks");
|
||||
|
||||
b.Navigation("ChapterRatings");
|
||||
|
||||
b.Navigation("Collections");
|
||||
|
||||
b.Navigation("DashboardStreams");
|
||||
@ -3363,10 +3460,16 @@ namespace API.Data.Migrations
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
{
|
||||
b.Navigation("ExternalRatings");
|
||||
|
||||
b.Navigation("ExternalReviews");
|
||||
|
||||
b.Navigation("Files");
|
||||
|
||||
b.Navigation("People");
|
||||
|
||||
b.Navigation("Ratings");
|
||||
|
||||
b.Navigation("UserProgress");
|
||||
});
|
||||
|
||||
|
@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
@ -24,7 +25,8 @@ public enum ChapterIncludes
|
||||
Files = 4,
|
||||
People = 8,
|
||||
Genres = 16,
|
||||
Tags = 32
|
||||
Tags = 32,
|
||||
ExternalReviews = 1 << 6,
|
||||
}
|
||||
|
||||
public interface IChapterRepository
|
||||
@ -48,6 +50,9 @@ 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);
|
||||
Task<IList<UserReviewDto>> GetExternalChapterReviews(int chapterId);
|
||||
Task<IList<RatingDto>> GetExternalChapterRatings(int chapterId);
|
||||
}
|
||||
public class ChapterRepository : IChapterRepository
|
||||
{
|
||||
@ -310,4 +315,39 @@ 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;
|
||||
}
|
||||
|
||||
public async Task<IList<UserReviewDto>> GetExternalChapterReviews(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.SelectMany(c => c.ExternalReviews)
|
||||
// Don't use ProjectTo, it fails to map int to float (??)
|
||||
.Select(r => _mapper.Map<UserReviewDto>(r))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<RatingDto>> GetExternalChapterRatings(int chapterId)
|
||||
{
|
||||
return await _context.Chapter
|
||||
.Where(c => c.Id == chapterId)
|
||||
.SelectMany(c => c.ExternalRatings)
|
||||
.ProjectTo<RatingDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
@ -65,7 +66,9 @@ 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 userId, int chapterId);
|
||||
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);
|
||||
@ -587,7 +590,14 @@ public class UserRepository : IUserRepository
|
||||
{
|
||||
return await _context.AppUserRating
|
||||
.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)
|
||||
.SingleOrDefaultAsync();
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserChapterRating?> GetUserChapterRatingAsync(int userId, int chapterId)
|
||||
{
|
||||
return await _context.AppUserChapterRating
|
||||
.Where(r => r.AppUserId == userId && r.ChapterId == chapterId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<UserReviewDto>> GetUserRatingDtosForSeriesAsync(int seriesId, int userId)
|
||||
@ -603,6 +613,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
|
||||
|
@ -19,6 +19,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
public ICollection<AppUserRole> UserRoles { get; set; } = null!;
|
||||
public ICollection<AppUserProgress> Progresses { get; set; } = null!;
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = null!;
|
||||
public ICollection<AppUserChapterRating> ChapterRatings { get; set; } = null!;
|
||||
public AppUserPreferences UserPreferences { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// Bookmarks associated with this User
|
||||
|
30
API/Entities/AppUserChapterRating.cs
Normal file
30
API/Entities/AppUserChapterRating.cs
Normal file
@ -0,0 +1,30 @@
|
||||
namespace API.Entities;
|
||||
|
||||
public class AppUserChapterRating
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// A number between 0-5.0 that represents how good a series is.
|
||||
/// </summary>
|
||||
public float Rating { get; set; }
|
||||
/// <summary>
|
||||
/// If the rating has been explicitly set. Otherwise, the 0.0 rating should be ignored as it's not rated
|
||||
/// </summary>
|
||||
public bool HasBeenRated { get; set; }
|
||||
/// <summary>
|
||||
/// A short summary the user can write when giving their review.
|
||||
/// </summary>
|
||||
public string? Review { get; set; }
|
||||
/// <summary>
|
||||
/// An optional tagline for the review
|
||||
/// </summary>
|
||||
public int SeriesId { get; set; }
|
||||
public Series Series { get; set; } = null!;
|
||||
|
||||
public int ChapterId { get; set; }
|
||||
public Chapter Chapter { get; set; } = null!;
|
||||
|
||||
// Relationships
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
}
|
@ -26,7 +26,6 @@ public class AppUserRating
|
||||
public int SeriesId { get; set; }
|
||||
public Series Series { get; set; } = null!;
|
||||
|
||||
|
||||
// Relationships
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Metadata;
|
||||
using API.Entities.Person;
|
||||
using API.Extensions;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
@ -125,6 +126,11 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public string WebLinks { get; set; } = string.Empty;
|
||||
public string ISBN { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// (Kavita+) Average rating from Kavita+ metadata
|
||||
/// </summary>
|
||||
public float AverageExternalRating { get; set; } = 0f;
|
||||
|
||||
#region Locks
|
||||
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
@ -160,6 +166,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
/// </summary>
|
||||
public ICollection<Genre> Genres { get; set; } = new List<Genre>();
|
||||
public ICollection<Tag> Tags { get; set; } = new List<Tag>();
|
||||
public ICollection<AppUserChapterRating> Ratings { get; set; } = [];
|
||||
|
||||
public ICollection<AppUserProgress> UserProgress { get; set; }
|
||||
|
||||
@ -168,6 +175,9 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public Volume Volume { get; set; } = null!;
|
||||
public int VolumeId { get; set; }
|
||||
|
||||
public ICollection<ExternalReview> ExternalReviews { get; set; } = [];
|
||||
public ICollection<ExternalRating> ExternalRatings { get; set; } = null!;
|
||||
|
||||
public void UpdateFrom(ParserInfo info)
|
||||
{
|
||||
Files ??= new List<MangaFile>();
|
||||
@ -192,8 +202,6 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
/// <returns></returns>
|
||||
public string GetNumberTitle()
|
||||
{
|
||||
// BUG: TODO: On non-english locales, for floats, the range will be 20,5 but the NumberTitle will return 20.5
|
||||
// Have I fixed this with TryParse CultureInvariant
|
||||
try
|
||||
{
|
||||
if (MinNumber.Is(MaxNumber))
|
||||
|
17
API/Entities/Enums/RatingAuthority.cs
Normal file
17
API/Entities/Enums/RatingAuthority.cs
Normal file
@ -0,0 +1,17 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums;
|
||||
|
||||
public enum RatingAuthority
|
||||
{
|
||||
/// <summary>
|
||||
/// Rating was from a User (internet or local)
|
||||
/// </summary>
|
||||
[Description("User")]
|
||||
User = 0,
|
||||
/// <summary>
|
||||
/// Rating was from Professional Critics
|
||||
/// </summary>
|
||||
[Description("Critic")]
|
||||
Critic = 1,
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.Entities.Metadata;
|
||||
@ -10,8 +11,13 @@ public class ExternalRating
|
||||
public int AverageScore { get; set; }
|
||||
public int FavoriteCount { get; set; }
|
||||
public ScrobbleProvider Provider { get; set; }
|
||||
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
|
||||
public string? ProviderUrl { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
/// <summary>
|
||||
/// This can be null when for a series-rating
|
||||
/// </summary>
|
||||
public int? ChapterId { get; set; }
|
||||
|
||||
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.Entities.Metadata;
|
||||
@ -20,6 +21,7 @@ public class ExternalReview
|
||||
/// </summary>
|
||||
public string RawBody { get; set; }
|
||||
public required ScrobbleProvider Provider { get; set; }
|
||||
public RatingAuthority Authority { get; set; } = RatingAuthority.User;
|
||||
public string SiteUrl { get; set; }
|
||||
/// <summary>
|
||||
/// Reviewer's username
|
||||
@ -37,6 +39,7 @@ public class ExternalReview
|
||||
|
||||
|
||||
public int SeriesId { get; set; }
|
||||
public int? ChapterId { get; set; }
|
||||
|
||||
// Relationships
|
||||
public ICollection<ExternalSeriesMetadata> ExternalSeriesMetadatas { get; set; } = null!;
|
||||
|
@ -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>();
|
||||
@ -84,6 +85,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddEasyCaching(options =>
|
||||
{
|
||||
options.UseInMemory(EasyCacheProfiles.Favicon);
|
||||
options.UseInMemory(EasyCacheProfiles.Publisher);
|
||||
options.UseInMemory(EasyCacheProfiles.Library);
|
||||
options.UseInMemory(EasyCacheProfiles.RevokedJwt);
|
||||
options.UseInMemory(EasyCacheProfiles.LocaleOptions);
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Linq;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Extensions.QueryExtensions;
|
||||
@ -72,6 +73,12 @@ public static class IncludesExtensions
|
||||
.Include(c => c.Tags);
|
||||
}
|
||||
|
||||
if (includes.HasFlag(ChapterIncludes.ExternalReviews))
|
||||
{
|
||||
queryable = queryable
|
||||
.Include(c => c.ExternalReviews);
|
||||
}
|
||||
|
||||
return queryable.AsSplitQuery();
|
||||
}
|
||||
|
||||
@ -253,6 +260,11 @@ public static class IncludesExtensions
|
||||
.ThenInclude(c => c.Items);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.ChapterRatings))
|
||||
{
|
||||
query = query.Include(u => u.ChapterRatings);
|
||||
}
|
||||
|
||||
return query.AsSplitQuery();
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
40
API/Helpers/Builders/AppUserChapterRatingBuilder.cs
Normal file
40
API/Helpers/Builders/AppUserChapterRatingBuilder.cs
Normal file
@ -0,0 +1,40 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using API.Entities;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class ChapterRatingBuilder : IEntityBuilder<AppUserChapterRating>
|
||||
{
|
||||
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 WithRating(int rating)
|
||||
{
|
||||
_rating.Rating = Math.Clamp(rating, 0, 5);
|
||||
return this;
|
||||
}
|
||||
|
||||
public ChapterRatingBuilder WithBody(string body)
|
||||
{
|
||||
_rating.Review = body;
|
||||
return this;
|
||||
}
|
||||
}
|
@ -1085,15 +1085,97 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
madeModification = await UpdateChapterPeople(chapter, settings, PersonRole.Writer, potentialMatch.Writers) || madeModification;
|
||||
|
||||
madeModification = await UpdateChapterCoverImage(chapter, settings, potentialMatch.CoverImageUrl) || madeModification;
|
||||
madeModification = UpdateExternalChapterMetadata(chapter, settings, potentialMatch) || madeModification;
|
||||
|
||||
_unitOfWork.ChapterRepository.Update(chapter);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
return madeModification;
|
||||
}
|
||||
|
||||
private bool UpdateExternalChapterMetadata(Chapter chapter, MetadataSettingsDto settings, ExternalChapterDto metadata)
|
||||
{
|
||||
if (!settings.Enabled) return false;
|
||||
|
||||
if (metadata.UserReviews.Count == 0 && metadata.CriticReviews.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var madeModification = false;
|
||||
|
||||
#region Review
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(chapter.ExternalReviews);
|
||||
List<ExternalReview> externalReviews = [];
|
||||
externalReviews.AddRange(metadata.CriticReviews
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
|
||||
.Select(r =>
|
||||
{
|
||||
var review = _mapper.Map<ExternalReview>(r);
|
||||
review.ChapterId = chapter.Id;
|
||||
review.Authority = RatingAuthority.Critic;
|
||||
CleanCbrReview(ref review);
|
||||
return review;
|
||||
}));
|
||||
externalReviews.AddRange(metadata.UserReviews
|
||||
.Where(r => !string.IsNullOrWhiteSpace(r.Username) && !string.IsNullOrWhiteSpace(r.Body))
|
||||
.Select(r =>
|
||||
{
|
||||
var review = _mapper.Map<ExternalReview>(r);
|
||||
review.ChapterId = chapter.Id;
|
||||
review.Authority = RatingAuthority.User;
|
||||
CleanCbrReview(ref review);
|
||||
return review;
|
||||
}));
|
||||
|
||||
chapter.ExternalReviews = externalReviews;
|
||||
madeModification = externalReviews.Count > 0;
|
||||
_logger.LogDebug("Added {Count} reviews for chapter {ChapterId}", externalReviews.Count, chapter.Id);
|
||||
#endregion
|
||||
|
||||
#region Rating
|
||||
|
||||
var averageCriticRating = metadata.CriticReviews.Average(r => r.Rating);
|
||||
var averageUserRating = metadata.UserReviews.Average(r => r.Rating);
|
||||
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(chapter.ExternalRatings);
|
||||
chapter.ExternalRatings =
|
||||
[
|
||||
new ExternalRating
|
||||
{
|
||||
AverageScore = (int) averageUserRating,
|
||||
Provider = ScrobbleProvider.Cbr,
|
||||
Authority = RatingAuthority.User,
|
||||
ProviderUrl = metadata.IssueUrl,
|
||||
},
|
||||
new ExternalRating
|
||||
{
|
||||
AverageScore = (int) averageCriticRating,
|
||||
Provider = ScrobbleProvider.Cbr,
|
||||
Authority = RatingAuthority.Critic,
|
||||
ProviderUrl = metadata.IssueUrl,
|
||||
|
||||
},
|
||||
];
|
||||
|
||||
chapter.AverageExternalRating = averageUserRating;
|
||||
|
||||
madeModification = averageUserRating > 0f || averageCriticRating > 0f || madeModification;
|
||||
|
||||
#endregion
|
||||
|
||||
return madeModification;
|
||||
}
|
||||
|
||||
private static void CleanCbrReview(ref ExternalReview review)
|
||||
{
|
||||
// CBR has Read Full Review which links to site, but we already have that
|
||||
review.Body = review.Body.Replace("Read Full Review", string.Empty).TrimEnd();
|
||||
review.RawBody = review.RawBody.Replace("Read Full Review", string.Empty).TrimEnd();
|
||||
review.BodyJustText = review.BodyJustText.Replace("Read Full Review", string.Empty).TrimEnd();
|
||||
}
|
||||
|
||||
|
||||
private static bool UpdateChapterSummary(Chapter chapter, MetadataSettingsDto settings, string? summary)
|
||||
{
|
||||
|
126
API/Services/RatingService.cs
Normal file
126
API/Services/RatingService.cs
Normal file
@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Services.Plus;
|
||||
using Hangfire;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IRatingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Updates the users' rating for a given series
|
||||
/// </summary>
|
||||
/// <param name="user">Should include ratings</param>
|
||||
/// <param name="updateRatingDto"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the users' rating for a given chapter
|
||||
/// </summary>
|
||||
/// <param name="user">Should include ratings</param>
|
||||
/// <param name="updateRatingDto">chapterId must be set</param>
|
||||
/// <returns></returns>
|
||||
Task<bool> UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto);
|
||||
}
|
||||
|
||||
public class RatingService: IRatingService
|
||||
{
|
||||
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IScrobblingService _scrobblingService;
|
||||
private readonly ILogger<RatingService> _logger;
|
||||
|
||||
public RatingService(IUnitOfWork unitOfWork, IScrobblingService scrobblingService, ILogger<RatingService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_scrobblingService = scrobblingService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSeriesRating(AppUser user, UpdateRatingDto updateRatingDto)
|
||||
{
|
||||
var userRating =
|
||||
await _unitOfWork.UserRepository.GetUserRatingAsync(updateRatingDto.SeriesId, user.Id) ??
|
||||
new AppUserRating();
|
||||
|
||||
try
|
||||
{
|
||||
userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f);
|
||||
userRating.HasBeenRated = true;
|
||||
userRating.SeriesId = updateRatingDto.SeriesId;
|
||||
|
||||
if (userRating.Id == 0)
|
||||
{
|
||||
user.Ratings ??= new List<AppUserRating>();
|
||||
user.Ratings.Add(userRating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
|
||||
{
|
||||
BackgroundJob.Enqueue(() =>
|
||||
_scrobblingService.ScrobbleRatingUpdate(user.Id, updateRatingDto.SeriesId,
|
||||
userRating.Rating));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception saving rating");
|
||||
}
|
||||
|
||||
await _unitOfWork.RollbackAsync();
|
||||
user.Ratings?.Remove(userRating);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateChapterRating(AppUser user, UpdateRatingDto updateRatingDto)
|
||||
{
|
||||
if (updateRatingDto.ChapterId == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var userRating =
|
||||
await _unitOfWork.UserRepository.GetUserChapterRatingAsync(user.Id, updateRatingDto.ChapterId.Value) ??
|
||||
new AppUserChapterRating();
|
||||
|
||||
try
|
||||
{
|
||||
userRating.Rating = Math.Clamp(updateRatingDto.UserRating, 0f, 5f);
|
||||
userRating.HasBeenRated = true;
|
||||
userRating.SeriesId = updateRatingDto.SeriesId;
|
||||
userRating.ChapterId = updateRatingDto.ChapterId.Value;
|
||||
|
||||
if (userRating.Id == 0)
|
||||
{
|
||||
user.ChapterRatings ??= new List<AppUserChapterRating>();
|
||||
user.ChapterRatings.Add(userRating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
await _unitOfWork.CommitAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception saving rating");
|
||||
}
|
||||
|
||||
await _unitOfWork.RollbackAsync();
|
||||
user.ChapterRatings?.Remove(userRating);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
@ -29,13 +29,11 @@ public interface ISeriesService
|
||||
{
|
||||
Task<SeriesDetailDto> GetSeriesDetail(int seriesId, int userId);
|
||||
Task<bool> UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto);
|
||||
Task<bool> UpdateRating(AppUser user, UpdateSeriesRatingDto updateSeriesRatingDto);
|
||||
Task<bool> DeleteMultipleSeries(IList<int> seriesIds);
|
||||
Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto);
|
||||
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
|
||||
Task<string> FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true);
|
||||
Task<string> FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true);
|
||||
|
||||
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle,
|
||||
bool withHash);
|
||||
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
|
||||
@ -447,57 +445,6 @@ public class SeriesService : ISeriesService
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="user">User with Ratings includes</param>
|
||||
/// <param name="updateSeriesRatingDto"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> UpdateRating(AppUser? user, UpdateSeriesRatingDto updateSeriesRatingDto)
|
||||
{
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogError("Cannot update rating of null user");
|
||||
return false;
|
||||
}
|
||||
|
||||
var userRating =
|
||||
await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ??
|
||||
new AppUserRating();
|
||||
try
|
||||
{
|
||||
userRating.Rating = Math.Clamp(updateSeriesRatingDto.UserRating, 0f, 5f);
|
||||
userRating.HasBeenRated = true;
|
||||
userRating.SeriesId = updateSeriesRatingDto.SeriesId;
|
||||
|
||||
if (userRating.Id == 0)
|
||||
{
|
||||
user.Ratings ??= new List<AppUserRating>();
|
||||
user.Ratings.Add(userRating);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
|
||||
{
|
||||
BackgroundJob.Enqueue(() =>
|
||||
_scrobblingService.ScrobbleRatingUpdate(user.Id, updateSeriesRatingDto.SeriesId,
|
||||
userRating.Rating));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception saving rating");
|
||||
}
|
||||
|
||||
await _unitOfWork.RollbackAsync();
|
||||
user.Ratings?.Remove(userRating);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMultipleSeries(IList<int> seriesIds)
|
||||
{
|
||||
try
|
||||
|
@ -45,6 +45,7 @@ public class CoverDbService : ICoverDbService
|
||||
private readonly IImageService _imageService;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IEventHub _eventHub;
|
||||
private TimeSpan _cacheTime = TimeSpan.FromDays(10);
|
||||
|
||||
private const string NewHost = "https://www.kavitareader.com/CoversDB/";
|
||||
|
||||
@ -97,7 +98,7 @@ public class CoverDbService : ICoverDbService
|
||||
throw new KavitaException($"Kavita has already tried to fetch from {sanitizedBaseUrl} and failed. Skipping duplicate check");
|
||||
}
|
||||
|
||||
await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
|
||||
await provider.SetAsync(baseUrl, string.Empty, _cacheTime);
|
||||
if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
|
||||
{
|
||||
url = value;
|
||||
@ -185,6 +186,17 @@ public class CoverDbService : ICoverDbService
|
||||
{
|
||||
try
|
||||
{
|
||||
// Sanitize user input
|
||||
publisherName = publisherName.Replace(Environment.NewLine, string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty);
|
||||
var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Publisher);
|
||||
var res = await provider.GetAsync<string>(publisherName);
|
||||
if (res.HasValue)
|
||||
{
|
||||
_logger.LogInformation("Kavita has already tried to fetch Publisher: {PublisherName} and failed. Skipping duplicate check", publisherName);
|
||||
throw new KavitaException($"Kavita has already tried to fetch Publisher: {publisherName} and failed. Skipping duplicate check");
|
||||
}
|
||||
|
||||
await provider.SetAsync(publisherName, string.Empty, _cacheTime);
|
||||
var publisherLink = await FallbackToKavitaReaderPublisher(publisherName);
|
||||
if (string.IsNullOrEmpty(publisherLink))
|
||||
{
|
||||
|
9
UI/Web/src/app/_models/chapter-detail-plus.ts
Normal file
9
UI/Web/src/app/_models/chapter-detail-plus.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {Rating} from "./rating";
|
||||
|
||||
export type ChapterDetailPlus = {
|
||||
rating: number;
|
||||
hasBeenRated: boolean;
|
||||
reviews: UserReview[];
|
||||
ratings: Rating[];
|
||||
};
|
@ -1,9 +1,15 @@
|
||||
import {ScrobbleProvider} from "../_services/scrobbling.service";
|
||||
|
||||
export enum RatingAuthority {
|
||||
User = 0,
|
||||
Critic = 1,
|
||||
}
|
||||
|
||||
export interface Rating {
|
||||
averageScore: number;
|
||||
meanScore: number;
|
||||
favoriteCount: number;
|
||||
provider: ScrobbleProvider;
|
||||
providerUrl: string | undefined;
|
||||
authority: RatingAuthority;
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {environment} from "../../environments/environment";
|
||||
import { HttpClient } from "@angular/common/http";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Chapter} from "../_models/chapter";
|
||||
import {TextResonse} from "../_types/text-response";
|
||||
import {ChapterDetailPlus} from "../_models/chapter-detail-plus";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -29,4 +30,8 @@ export class ChapterService {
|
||||
return this.httpClient.post(this.baseUrl + 'chapter/update', chapter, TextResonse);
|
||||
}
|
||||
|
||||
chapterDetailPlus(seriesId: number, chapterId: number) {
|
||||
return this.httpClient.get<ChapterDetailPlus>(this.baseUrl + `chapter/chapter-detail-plus?chapterId=${chapterId}&seriesId=${seriesId}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
56
UI/Web/src/app/_services/review.service.ts
Normal file
56
UI/Web/src/app/_services/review.service.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Rating} from "../_models/rating";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReviewService {
|
||||
|
||||
private baseUrl = environment.apiUrl;
|
||||
|
||||
constructor(private httpClient: HttpClient) { }
|
||||
|
||||
deleteReview(seriesId: number, chapterId?: number) {
|
||||
if (chapterId) {
|
||||
return this.httpClient.delete(this.baseUrl + `review/chapter?chapterId=${chapterId}`);
|
||||
}
|
||||
|
||||
return this.httpClient.delete(this.baseUrl + `review/series?seriesId=${seriesId}`);
|
||||
}
|
||||
|
||||
updateReview(seriesId: number, body: string, chapterId?: number) {
|
||||
if (chapterId) {
|
||||
return this.httpClient.post<UserReview>(this.baseUrl + `review/chapter`, {
|
||||
seriesId, chapterId, body
|
||||
});
|
||||
}
|
||||
|
||||
return this.httpClient.post<UserReview>(this.baseUrl + 'review/series', {
|
||||
seriesId, body
|
||||
});
|
||||
}
|
||||
|
||||
updateRating(seriesId: number, userRating: number, chapterId?: number) {
|
||||
if (chapterId) {
|
||||
return this.httpClient.post(this.baseUrl + 'rating/chapter', {
|
||||
seriesId, chapterId, userRating
|
||||
})
|
||||
}
|
||||
|
||||
return this.httpClient.post(this.baseUrl + 'rating/series', {
|
||||
seriesId, userRating
|
||||
})
|
||||
}
|
||||
|
||||
overallRating(seriesId: number, chapterId?: number) {
|
||||
if (chapterId) {
|
||||
return this.httpClient.get<Rating>(this.baseUrl + `rating/overall-chapter?chapterId=${chapterId}`);
|
||||
}
|
||||
|
||||
return this.httpClient.get<Rating>(this.baseUrl + `rating/overall-series?seriesId=${seriesId}`);
|
||||
}
|
||||
|
||||
}
|
@ -203,27 +203,9 @@ export class SeriesService {
|
||||
return this.httpClient.get<SeriesDetail>(this.baseUrl + 'series/series-detail?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
deleteReview(seriesId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + 'review?seriesId=' + seriesId);
|
||||
}
|
||||
updateReview(seriesId: number, body: string) {
|
||||
return this.httpClient.post<UserReview>(this.baseUrl + 'review', {
|
||||
seriesId, body
|
||||
});
|
||||
}
|
||||
|
||||
getReviews(seriesId: number) {
|
||||
return this.httpClient.get<Array<UserReview>>(this.baseUrl + 'review?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
getRatings(seriesId: number) {
|
||||
return this.httpClient.get<Array<Rating>>(this.baseUrl + 'rating?seriesId=' + seriesId);
|
||||
}
|
||||
getOverallRating(seriesId: number) {
|
||||
return this.httpClient.get<Rating>(this.baseUrl + 'rating/overall?seriesId=' + seriesId);
|
||||
}
|
||||
|
||||
removeFromOnDeck(seriesId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {});
|
||||
|
@ -28,4 +28,5 @@ export class VolumeService {
|
||||
updateVolume(volume: any) {
|
||||
return this.httpClient.post(this.baseUrl + 'volume/update', volume, TextResonse);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -34,7 +34,11 @@
|
||||
<div class="d-flex pt-3 justify-content-between">
|
||||
@if ((item.series.volumes || 0) > 0 || (item.series.chapters || 0) > 0) {
|
||||
<span class="me-1">{{t('volume-count', {num: item.series.volumes})}}</span>
|
||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
|
||||
@if (item.series.plusMediaFormat === PlusMediaFormat.Comic) {
|
||||
<span class="me-1">{{t('issue-count', {num: item.series.chapters})}}</span>
|
||||
} @else {
|
||||
<span class="me-1">{{t('chapter-count', {num: item.series.chapters})}}</span>
|
||||
}
|
||||
} @else {
|
||||
<span class="me-1">{{t('releasing')}}</span>
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {PlusMediaFormat} from "../../_models/series-detail/external-series-detail";
|
||||
|
||||
@Component({
|
||||
selector: 'app-match-series-result-item',
|
||||
@ -47,4 +48,5 @@ export class MatchSeriesResultItemComponent {
|
||||
this.selected.emit(this.item);
|
||||
}
|
||||
|
||||
protected readonly PlusMediaFormat = PlusMediaFormat;
|
||||
}
|
||||
|
@ -27,6 +27,9 @@
|
||||
{{review.username}}
|
||||
}
|
||||
{{(isMyReview ? '' : review.username | defaultValue:'')}}
|
||||
@if (review.authority === RatingAuthority.Critic) {
|
||||
({{t('critic')}})
|
||||
}
|
||||
</div>
|
||||
@if (review.isExternal){
|
||||
<span class="review-score">{{t('rating-percentage', {r: review.score})}}</span>
|
||||
|
@ -13,15 +13,13 @@ import {UserReview} from "./user-review";
|
||||
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,
|
||||
ReviewSeriesModalComponent
|
||||
} from "../review-series-modal/review-series-modal.component";
|
||||
import {ReviewModalCloseEvent, ReviewModalComponent} from "../review-modal/review-modal.component";
|
||||
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {ProviderImagePipe} from "../../_pipes/provider-image.pipe";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ScrobbleProvider} from "../../_services/scrobbling.service";
|
||||
import {RatingAuthority} from "../../_models/rating";
|
||||
|
||||
@Component({
|
||||
selector: 'app-review-card',
|
||||
@ -35,7 +33,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;
|
||||
|
||||
@ -44,7 +42,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();
|
||||
}
|
||||
});
|
||||
@ -53,16 +51,19 @@ export class ReviewCardComponent implements OnInit {
|
||||
showModal() {
|
||||
let component;
|
||||
if (this.isMyReview) {
|
||||
component = ReviewSeriesModalComponent;
|
||||
component = ReviewModalComponent;
|
||||
} else {
|
||||
component = ReviewCardModalComponent;
|
||||
}
|
||||
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);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
protected readonly RatingAuthority = RatingAuthority;
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import {ScrobbleProvider} from "../../_services/scrobbling.service";
|
||||
import {RatingAuthority} from "../../_models/rating";
|
||||
|
||||
|
||||
export interface UserReview {
|
||||
seriesId: number;
|
||||
libraryId: number;
|
||||
chapterId?: number;
|
||||
score: number;
|
||||
username: string;
|
||||
body: string;
|
||||
@ -11,4 +14,5 @@ export interface UserReview {
|
||||
bodyJustText?: string;
|
||||
siteUrl?: string;
|
||||
provider: ScrobbleProvider;
|
||||
authority: RatingAuthority;
|
||||
}
|
||||
|
@ -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>
|
||||
@ -8,6 +8,11 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form [formGroup]="reviewGroup">
|
||||
|
||||
<!--Not in sync with one at the top of the detail page, bad UX for now -->
|
||||
<!--<ngx-stars [initialStars]="review.rating" (ratingOutput)="updateRating($event)" [size]="2"
|
||||
[maxStars]="5" [color]="starColor"></ngx-stars>-->
|
||||
|
||||
<div class="row g-0 mt-2">
|
||||
<label for="review" class="form-label">{{t('review-label')}}</label>
|
||||
<textarea id="review" class="form-control" formControlName="reviewBody" rows="3" [minlength]="minLength"
|
@ -6,30 +6,35 @@ import {UserReview} from "../review-card/user-review";
|
||||
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";
|
||||
import {NgxStarsModule} from "ngx-stars";
|
||||
import {ThemeService} from "../../_services/theme.service";
|
||||
import {ReviewService} from "../../_services/review.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({
|
||||
selector: 'app-review-series-modal',
|
||||
imports: [ReactiveFormsModule, TranslocoDirective],
|
||||
templateUrl: './review-series-modal.component.html',
|
||||
styleUrls: ['./review-series-modal.component.scss'],
|
||||
imports: [ReactiveFormsModule, TranslocoDirective, NgxStarsModule],
|
||||
templateUrl: './review-modal.component.html',
|
||||
styleUrls: ['./review-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReviewSeriesModalComponent implements OnInit {
|
||||
export class ReviewModalComponent implements OnInit {
|
||||
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly reviewService = inject(ReviewService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
@ -46,23 +51,27 @@ export class ReviewSeriesModalComponent implements OnInit {
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!await this.confirmService.confirm(translate('toasts.delete-review'))) return;
|
||||
this.seriesService.deleteReview(this.review.seriesId).subscribe(() => {
|
||||
|
||||
this.reviewService.deleteReview(this.review.seriesId, this.review.chapterId).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});
|
||||
});
|
||||
|
||||
}
|
||||
save() {
|
||||
const model = this.reviewGroup.value;
|
||||
if (model.reviewBody.length < this.minLength) {
|
||||
return;
|
||||
}
|
||||
this.seriesService.updateReview(this.review.seriesId, model.reviewBody).subscribe(review => {
|
||||
this.modal.close({success: true, review: review, action: ReviewSeriesModalCloseAction.Edit});
|
||||
|
||||
this.reviewService.updateReview(this.review.seriesId, model.reviewBody, this.review.chapterId).subscribe(review => {
|
||||
this.modal.close({success: true, review: review, action: ReviewModalCloseAction.Edit});
|
||||
});
|
||||
|
||||
}
|
||||
}
|
17
UI/Web/src/app/_single-module/reviews/reviews.component.html
Normal file
17
UI/Web/src/app/_single-module/reviews/reviews.component.html
Normal file
@ -0,0 +1,17 @@
|
||||
<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-{{this.getUserReviews().length > 0 ? 'pen' : 'plus'}}"
|
||||
[clickableTitle]=true (sectionClick)="openReviewModal()">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" *transloco="let t;prefix:'reviews'">
|
||||
<app-carousel-reel [items]="plusReviews" [alwaysShow]="false" [title]="t('user-reviews-plus')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
103
UI/Web/src/app/_single-module/reviews/reviews.component.ts
Normal file
103
UI/Web/src/app/_single-module/reviews/reviews.component.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, Input, OnInit} from '@angular/core';
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {ReviewCardComponent} from "../review-card/review-card.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {UserReview} from "../review-card/user-review";
|
||||
import {User} from "../../_models/user";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {
|
||||
ReviewModalComponent, ReviewModalCloseAction,
|
||||
ReviewModalCloseEvent
|
||||
} from "../review-modal/review-modal.component";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {NgbModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {Series} from "../../_models/series";
|
||||
import {Volume} from "../../_models/volume";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
|
||||
@Component({
|
||||
selector: 'app-reviews',
|
||||
imports: [
|
||||
CarouselReelComponent,
|
||||
ReviewCardComponent,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './reviews.component.html',
|
||||
styleUrl: './reviews.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReviewsComponent {
|
||||
|
||||
@Input({required: true}) userReviews!: Array<UserReview>;
|
||||
@Input({required: true}) plusReviews!: Array<UserReview>;
|
||||
@Input({required: true}) series!: Series;
|
||||
@Input() volumeId: number | undefined;
|
||||
@Input() chapter: Chapter | undefined;
|
||||
|
||||
user: User | undefined;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private modalService: NgbModal,
|
||||
private cdRef: ChangeDetectorRef) {
|
||||
|
||||
this.accountService.currentUser$.subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openReviewModal() {
|
||||
const userReview = this.getUserReviews();
|
||||
|
||||
const modalRef = this.modalService.open(ReviewModalComponent, DefaultModalOptions);
|
||||
|
||||
if (userReview.length > 0) {
|
||||
modalRef.componentInstance.review = userReview[0];
|
||||
} else {
|
||||
modalRef.componentInstance.review = {
|
||||
seriesId: this.series.id,
|
||||
volumeId: this.volumeId,
|
||||
chapterId: this.chapter?.id,
|
||||
tagline: '',
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
|
||||
modalRef.closed.subscribe((closeResult) => {
|
||||
this.updateOrDeleteReview(closeResult);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
updateOrDeleteReview(closeResult: ReviewModalCloseEvent) {
|
||||
if (closeResult.action === ReviewModalCloseAction.Close) return;
|
||||
|
||||
const index = this.userReviews.findIndex(r => r.username === closeResult.review!.username);
|
||||
if (closeResult.action === ReviewModalCloseAction.Edit) {
|
||||
if (index === -1 ) {
|
||||
this.userReviews = [closeResult.review, ...this.userReviews];
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
this.userReviews[index] = closeResult.review;
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeResult.action === ReviewModalCloseAction.Delete) {
|
||||
this.userReviews = [...this.userReviews.filter(r => r.username !== closeResult.review!.username)];
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getUserReviews() {
|
||||
if (!this.user) {
|
||||
return [];
|
||||
}
|
||||
return this.userReviews.filter(r => r.username === this.user?.username && !r.isExternal);
|
||||
}
|
||||
|
||||
}
|
@ -27,15 +27,16 @@
|
||||
|
||||
|
||||
|
||||
<!-- 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">
|
||||
<app-external-rating [seriesId]="series.id"
|
||||
[ratings]="ratings"
|
||||
[userRating]="rating"
|
||||
[hasUserRated]="hasBeenRated"
|
||||
[libraryType]="libraryType!"
|
||||
[chapterId]="chapterId"
|
||||
>
|
||||
</app-external-rating>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 mb-3">
|
||||
<div class="row g-0">
|
||||
@ -175,6 +176,19 @@
|
||||
</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" [chapter]="chapter" [volumeId]="chapter.volumeId" />
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
@if(readingLists.length > 0) {
|
||||
<li [ngbNavItem]="TabID.Related">
|
||||
<a ngbNavLink>{{t('related-tab')}}</a>
|
||||
|
@ -1,23 +1,28 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, DatePipe, Location} from "@angular/common";
|
||||
import {AsyncPipe, DatePipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
import {
|
||||
NgbDropdown,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownMenu,
|
||||
NgbDropdownToggle, NgbModal,
|
||||
NgbNav, NgbNavChangeEvent,
|
||||
NgbNavContent, NgbNavItem,
|
||||
NgbNavLink, NgbNavOutlet,
|
||||
NgbDropdownToggle,
|
||||
NgbModal,
|
||||
NgbNav,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
NgbNavOutlet,
|
||||
NgbTooltip
|
||||
} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
@ -65,45 +70,52 @@ import {ActionService} from "../_services/action.service";
|
||||
import {DefaultDatePipe} from "../_pipes/default-date.pipe";
|
||||
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 {User} from "../_models/user";
|
||||
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',
|
||||
Reviews = 'review-tab', // Only applicable for books
|
||||
Reviews = 'review-tab',
|
||||
Details = 'details-tab'
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
LoadingComponent,
|
||||
NgbDropdown,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownMenu,
|
||||
NgbDropdownToggle,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavLink,
|
||||
NgbTooltip,
|
||||
VirtualScrollerModule,
|
||||
NgStyle,
|
||||
NgClass,
|
||||
TranslocoDirective,
|
||||
ReadMoreComponent,
|
||||
NgbNavItem,
|
||||
NgbNavOutlet,
|
||||
DetailsTabComponent,
|
||||
RouterLink,
|
||||
EntityTitleComponent,
|
||||
RelatedTabComponent,
|
||||
BadgeExpanderComponent,
|
||||
MetadataDetailRowComponent,
|
||||
DownloadButtonComponent,
|
||||
DatePipe,
|
||||
DefaultDatePipe,
|
||||
CoverImageComponent
|
||||
],
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
LoadingComponent,
|
||||
NgbDropdown,
|
||||
NgbDropdownItem,
|
||||
NgbDropdownMenu,
|
||||
NgbDropdownToggle,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavLink,
|
||||
NgbTooltip,
|
||||
VirtualScrollerModule,
|
||||
NgStyle,
|
||||
NgClass,
|
||||
TranslocoDirective,
|
||||
ReadMoreComponent,
|
||||
NgbNavItem,
|
||||
NgbNavOutlet,
|
||||
DetailsTabComponent,
|
||||
RouterLink,
|
||||
EntityTitleComponent,
|
||||
RelatedTabComponent,
|
||||
BadgeExpanderComponent,
|
||||
MetadataDetailRowComponent,
|
||||
DownloadButtonComponent,
|
||||
DatePipe,
|
||||
DefaultDatePipe,
|
||||
CoverImageComponent,
|
||||
ReviewsComponent,
|
||||
ExternalRatingComponent
|
||||
],
|
||||
templateUrl: './chapter-detail.component.html',
|
||||
styleUrl: './chapter-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -138,6 +150,8 @@ export class ChapterDetailComponent implements OnInit {
|
||||
protected readonly TabID = TabID;
|
||||
protected readonly FilterField = FilterField;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly encodeURIComponent = encodeURIComponent;
|
||||
|
||||
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||
@ -151,6 +165,12 @@ export class ChapterDetailComponent implements OnInit {
|
||||
series: Series | null = null;
|
||||
libraryType: LibraryType | null = null;
|
||||
hasReadingProgress = false;
|
||||
userReviews: Array<UserReview> = [];
|
||||
plusReviews: Array<UserReview> = [];
|
||||
rating: number = 0;
|
||||
ratings: Array<Rating> = [];
|
||||
hasBeenRated: boolean = false;
|
||||
|
||||
weblinks: Array<string> = [];
|
||||
activeTabId = TabID.Details;
|
||||
/**
|
||||
@ -163,6 +183,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)';
|
||||
@ -177,6 +198,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');
|
||||
@ -211,7 +238,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),
|
||||
chapterDetail: this.chapterService.chapterDetailPlus(this.seriesId, this.chapterId),
|
||||
}).subscribe(results => {
|
||||
|
||||
if (results.chapter === null) {
|
||||
@ -223,6 +251,11 @@ export class ChapterDetailComponent implements OnInit {
|
||||
this.chapter = results.chapter;
|
||||
this.weblinks = this.chapter.webLinks.split(',');
|
||||
this.libraryType = results.libraryType;
|
||||
this.userReviews = results.chapterDetail.reviews.filter(r => !r.isExternal);
|
||||
this.plusReviews = results.chapterDetail.reviews.filter(r => r.isExternal);
|
||||
this.rating = results.chapterDetail.rating;
|
||||
this.hasBeenRated = results.chapterDetail.hasBeenRated;
|
||||
this.ratings = results.chapterDetail.ratings;
|
||||
|
||||
this.themeService.setColorScape(this.chapter.primaryColor, this.chapter.secondaryColor);
|
||||
|
||||
@ -246,6 +279,7 @@ export class ChapterDetailComponent implements OnInit {
|
||||
|
||||
this.showDetailsTab = hasAnyCast(this.chapter) || (this.chapter.genres || []).length > 0 ||
|
||||
(this.chapter.tags || []).length > 0 || this.chapter.webLinks.length > 0;
|
||||
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
@ -361,7 +395,4 @@ export class ChapterDetailComponent implements OnInit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly encodeURIComponent = encodeURIComponent;
|
||||
}
|
||||
|
@ -17,7 +17,7 @@
|
||||
|
||||
@for (rating of ratings; track rating.provider + rating.averageScore) {
|
||||
<div class="col-auto custom-col clickable" [ngbPopover]="externalPopContent" [popoverContext]="{rating: rating}"
|
||||
[popoverTitle]="rating.provider | scrobbleProviderName" popoverClass="sm-popover">
|
||||
[popoverTitle]="(rating.provider | scrobbleProviderName) + getAuthorityTitle(rating)" popoverClass="sm-popover">
|
||||
<span class="badge rounded-pill me-1">
|
||||
<img class="me-1" [ngSrc]="rating.provider | providerImage:true" width="24" height="24" alt="" aria-hidden="true">
|
||||
{{rating.averageScore}}%
|
||||
@ -70,6 +70,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@if (rating.providerUrl) {
|
||||
<a [href]="rating.providerUrl" target="_blank" rel="noreferrer nofollow">{{t('entry-label')}}</a>
|
||||
}
|
||||
|
@ -8,8 +8,7 @@ import {
|
||||
OnInit,
|
||||
ViewEncapsulation
|
||||
} from '@angular/core';
|
||||
import {SeriesService} from "../../../_services/series.service";
|
||||
import {Rating} from "../../../_models/rating";
|
||||
import {Rating, RatingAuthority} from "../../../_models/rating";
|
||||
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
|
||||
import {NgbModal, NgbPopover} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||
@ -18,12 +17,13 @@ import {NgxStarsModule} from "ngx-stars";
|
||||
import {ThemeService} from "../../../_services/theme.service";
|
||||
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
|
||||
import {ImageComponent} from "../../../shared/image/image.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
|
||||
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 {ReviewService} from "../../../_services/review.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-rating',
|
||||
@ -37,7 +37,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
|
||||
export class ExternalRatingComponent implements OnInit {
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly reviewService = inject(ReviewService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
@ -47,6 +47,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 +59,13 @@ export class ExternalRatingComponent implements OnInit {
|
||||
starColor = this.themeService.getCssVariable('--rating-star-color');
|
||||
|
||||
ngOnInit() {
|
||||
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
|
||||
this.reviewService.overallRating(this.seriesId, this.chapterId).subscribe(r => {
|
||||
this.overallRating = r.averageScore;
|
||||
});
|
||||
}
|
||||
|
||||
updateRating(rating: number) {
|
||||
this.seriesService.updateRating(this.seriesId, rating).subscribe(() => {
|
||||
this.reviewService.updateRating(this.seriesId, rating, this.chapterId).subscribe(() => {
|
||||
this.userRating = rating;
|
||||
this.hasUserRated = true;
|
||||
this.cdRef.markForCheck();
|
||||
@ -81,4 +84,14 @@ export class ExternalRatingComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
getAuthorityTitle(rating: Rating) {
|
||||
if (rating.authority === RatingAuthority.Critic) {
|
||||
return ` (${translate('external-rating.critic')})`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
protected readonly RatingAuthority = RatingAuthority;
|
||||
}
|
||||
|
@ -36,7 +36,6 @@
|
||||
[mangaFormat]="series.format">
|
||||
</app-metadata-detail-row>
|
||||
|
||||
<!-- Rating goes here (after I implement support for rating individual issues -->
|
||||
<div class="mt-2 mb-2">
|
||||
<app-external-rating [seriesId]="series.id"
|
||||
[ratings]="ratings"
|
||||
@ -315,25 +314,8 @@
|
||||
</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabID.Reviews; prefetch on idle) {
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="reviews" [alwaysShow]="true" [title]="t('user-reviews-local')"
|
||||
iconClasses="fa-solid fa-{{getUserReview().length > 0 ? 'pen' : 'plus'}}"
|
||||
[clickableTitle]="true" (sectionClick)="openReviewModal()">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item" (refresh)="updateOrDeleteReview($event)"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<app-carousel-reel [items]="plusReviews" [alwaysShow]="false" [title]="t('user-reviews-plus')">
|
||||
<ng-template #carouselItem let-item let-position="idx">
|
||||
<app-review-card [review]="item"></app-review-card>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
</div>
|
||||
<app-reviews [userReviews]="reviews" [plusReviews]="plusReviews" [series]="series" />
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
|
@ -62,10 +62,10 @@ 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,
|
||||
ReviewSeriesModalComponent
|
||||
} from '../../../_single-module/review-series-modal/review-series-modal.component';
|
||||
ReviewModalCloseAction,
|
||||
ReviewModalCloseEvent,
|
||||
ReviewModalComponent
|
||||
} from '../../../_single-module/review-modal/review-modal.component';
|
||||
import {PageLayoutMode} from 'src/app/_models/page-layout-mode';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {UserReview} from "../../../_single-module/review-card/user-review";
|
||||
@ -116,6 +116,7 @@ import {DefaultModalOptions} from "../../../_models/default-modal-options";
|
||||
import {LicenseService} from "../../../_services/license.service";
|
||||
import {PageBookmark} from "../../../_models/readers/page-bookmark";
|
||||
import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event";
|
||||
import {ReviewsComponent} from "../../../_single-module/reviews/reviews.component";
|
||||
|
||||
|
||||
enum TabID {
|
||||
@ -140,14 +141,14 @@ interface StoryLineItem {
|
||||
templateUrl: './series-detail.component.html',
|
||||
styleUrls: ['./series-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle,
|
||||
NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
|
||||
NgbDropdownItem, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent,
|
||||
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
|
||||
TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
|
||||
NgClass, AsyncPipe, DetailsTabComponent, ChapterCardComponent,
|
||||
VolumeCardComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent,
|
||||
PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent]
|
||||
imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle,
|
||||
NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu,
|
||||
NgbDropdownItem, BulkOperationsComponent,
|
||||
NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, SeriesCardComponent, ExternalSeriesCardComponent, NgbNavOutlet,
|
||||
TranslocoDirective, NgTemplateOutlet, NextExpectedCardComponent,
|
||||
NgClass, AsyncPipe, DetailsTabComponent, ChapterCardComponent,
|
||||
VolumeCardComponent, DefaultValuePipe, ExternalRatingComponent, ReadMoreComponent, RouterLink, BadgeExpanderComponent,
|
||||
PublicationStatusPipe, MetadataDetailRowComponent, DownloadButtonComponent, RelatedTabComponent, CoverImageComponent, ReviewsComponent]
|
||||
})
|
||||
export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
|
||||
@ -1137,56 +1138,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
});
|
||||
}
|
||||
|
||||
getUserReview() {
|
||||
return this.reviews.filter(r => r.username === this.user?.username && !r.isExternal);
|
||||
}
|
||||
|
||||
openReviewModal() {
|
||||
const userReview = this.getUserReview();
|
||||
|
||||
const modalRef = this.modalService.open(ReviewSeriesModalComponent, DefaultModalOptions);
|
||||
modalRef.componentInstance.series = this.series;
|
||||
if (userReview.length > 0) {
|
||||
modalRef.componentInstance.review = userReview[0];
|
||||
} else {
|
||||
modalRef.componentInstance.review = {
|
||||
seriesId: this.series.id,
|
||||
tagline: '',
|
||||
body: ''
|
||||
};
|
||||
}
|
||||
|
||||
modalRef.closed.subscribe((closeResult) => {
|
||||
this.updateOrDeleteReview(closeResult);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
updateOrDeleteReview(closeResult: ReviewSeriesModalCloseEvent) {
|
||||
if (closeResult.action === ReviewSeriesModalCloseAction.Close) return;
|
||||
|
||||
const index = this.reviews.findIndex(r => r.username === closeResult.review!.username);
|
||||
if (closeResult.action === ReviewSeriesModalCloseAction.Edit) {
|
||||
if (index === -1 ) {
|
||||
// A new series was added:
|
||||
this.reviews = [closeResult.review, ...this.reviews];
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
// An edit occurred
|
||||
this.reviews[index] = closeResult.review;
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeResult.action === ReviewSeriesModalCloseAction.Delete) {
|
||||
// An edit occurred
|
||||
this.reviews = [...this.reviews.filter(r => r.username !== closeResult.review!.username)];
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
|
@ -29,17 +29,17 @@
|
||||
[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">
|
||||
<app-external-rating [seriesId]="series.id"
|
||||
[ratings]="[]"
|
||||
[userRating]="rating"
|
||||
[hasUserRated]="hasBeenRated"
|
||||
[libraryType]="libraryType"
|
||||
[chapterId]="volume.chapters[0].id"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mt-2 mb-3">
|
||||
<div class="row g-0">
|
||||
@ -191,6 +191,21 @@
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (volume.chapters.length === 1) {
|
||||
<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" [chapter]="volume.chapters[0]" />
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li [ngbNavItem]="TabID.Details" id="details-tab">
|
||||
<a ngbNavLink>{{t('details-tab')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {AsyncPipe, DOCUMENT, NgStyle, NgClass, Location} from "@angular/common";
|
||||
import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
|
||||
import {ActivatedRoute, Router, RouterLink} from "@angular/router";
|
||||
import {ImageService} from "../_services/image.service";
|
||||
import {SeriesService} from "../_services/series.service";
|
||||
@ -54,9 +54,7 @@ import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service";
|
||||
import {Breakpoint, UtilityService} from "../shared/_services/utility.service";
|
||||
import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component";
|
||||
import {
|
||||
EditVolumeModalComponent
|
||||
} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
|
||||
import {EditVolumeModalComponent} from "../_single-module/edit-volume-modal/edit-volume-modal.component";
|
||||
import {Genre} from "../_models/metadata/genre";
|
||||
import {Tag} from "../_models/tag";
|
||||
import {RelatedTabComponent} from "../_single-module/related-tab/related-tab.component";
|
||||
@ -78,6 +76,10 @@ 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";
|
||||
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
|
||||
import {ChapterService} from "../_services/chapter.service";
|
||||
|
||||
enum TabID {
|
||||
|
||||
@ -119,36 +121,38 @@ 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,
|
||||
ExternalRatingComponent
|
||||
],
|
||||
templateUrl: './volume-detail.component.html',
|
||||
styleUrl: './volume-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -176,6 +180,7 @@ export class VolumeDetailComponent implements OnInit {
|
||||
private readonly readingListService = inject(ReadingListService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly location = inject(Location);
|
||||
private readonly chapterService = inject(ChapterService);
|
||||
|
||||
|
||||
protected readonly AgeRating = AgeRating;
|
||||
@ -196,6 +201,13 @@ export class VolumeDetailComponent implements OnInit {
|
||||
libraryType: LibraryType | null = null;
|
||||
activeTabId = TabID.Chapters;
|
||||
readingLists: ReadingList[] = [];
|
||||
|
||||
// Only populated if the volume has exactly one chapter
|
||||
userReviews: Array<UserReview> = [];
|
||||
plusReviews: Array<UserReview> = [];
|
||||
rating: number = 0;
|
||||
hasBeenRated: boolean = false;
|
||||
|
||||
mobileSeriesImgBackground: string | undefined;
|
||||
downloadInProgress: boolean = false;
|
||||
|
||||
@ -374,7 +386,7 @@ 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),
|
||||
}).subscribe(results => {
|
||||
|
||||
if (results.volume === null) {
|
||||
@ -386,6 +398,15 @@ export class VolumeDetailComponent implements OnInit {
|
||||
this.volume = results.volume;
|
||||
this.libraryType = results.libraryType;
|
||||
|
||||
if (this.volume.chapters.length === 1) {
|
||||
this.chapterService.chapterDetailPlus(this.seriesId, this.volume.chapters[0].id).subscribe(detail => {
|
||||
this.userReviews = detail.reviews.filter(r => !r.isExternal);
|
||||
this.plusReviews = detail.reviews.filter(r => r.isExternal);
|
||||
this.rating = detail.rating;
|
||||
this.hasBeenRated = detail.hasBeenRated;
|
||||
});
|
||||
}
|
||||
|
||||
this.themeService.setColorScape(this.volume!.primaryColor, this.volume!.secondaryColor);
|
||||
|
||||
// Set up the download in progress
|
||||
|
@ -67,8 +67,12 @@
|
||||
"spoiler": {
|
||||
"click-to-show": "Spoiler, click to show"
|
||||
},
|
||||
"reviews": {
|
||||
"user-reviews-local": "Local Reviews",
|
||||
"user-reviews-plus": "External Reviews"
|
||||
},
|
||||
|
||||
"review-series-modal": {
|
||||
"review-modal": {
|
||||
"title": "Edit Review",
|
||||
"review-label": "Review",
|
||||
"close": "{{common.close}}",
|
||||
@ -89,7 +93,8 @@
|
||||
"your-review": "This is your review",
|
||||
"external-review": "External Review",
|
||||
"local-review": "Local Review",
|
||||
"rating-percentage": "Rating {{r}}%"
|
||||
"rating-percentage": "Rating {{r}}%",
|
||||
"critic": "critic"
|
||||
},
|
||||
|
||||
"want-to-read": {
|
||||
@ -958,9 +963,6 @@
|
||||
"no-chapters": "There are no chapters to this volume. Cannot read.",
|
||||
"cover-change": "It can take up to a minute for your browser to refresh the image. Until then, the old image may be shown on some pages.",
|
||||
|
||||
"user-reviews-local": "Local Reviews",
|
||||
"user-reviews-plus": "External Reviews",
|
||||
|
||||
"writers-title": "{{metadata-fields.writers-title}}",
|
||||
"cover-artists-title": "{{metadata-fields.cover-artists-title}}",
|
||||
"characters-title": "{{metadata-fields.characters-title}}",
|
||||
@ -1010,6 +1012,7 @@
|
||||
"match-series-result-item": {
|
||||
"volume-count": "{{server-stats.volume-count}}",
|
||||
"chapter-count": "{{common.chapter-count}}",
|
||||
"issue-count": "{{common.issue-count}}",
|
||||
"releasing": "Releasing",
|
||||
"details": "View page",
|
||||
"updating-metadata-status": "Updating Metadata"
|
||||
@ -1047,7 +1050,8 @@
|
||||
"entry-label": "See Details",
|
||||
"kavita-tooltip": "Your Rating + Overall",
|
||||
"kavita-rating-title": "Your Rating",
|
||||
"close": "{{common.close}}"
|
||||
"close": "{{common.close}}",
|
||||
"critic": "{{review-card.critic}}"
|
||||
},
|
||||
|
||||
"badge-expander": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user