diff --git a/.gitignore b/.gitignore index ef856c7863..e226d00c77 100644 --- a/.gitignore +++ b/.gitignore @@ -448,4 +448,38 @@ appsettings.json /API/kavita.db-wal /API/Hangfire.db /API/Hangfire-log.db -cache/ \ No newline at end of file +cache/ +/API/wwwroot/assets/images/image-placeholder.jpg +/API/wwwroot/assets/images/mock-cover.jpg +/API/wwwroot/assets/images/preset-light.png +/API/wwwroot/assets/themes/plex/_bootswatch.scss +/API/wwwroot/assets/themes/plex/_variables.scss +/API/wwwroot/admin-admin-module.js +/API/wwwroot/admin-admin-module.js.map +/API/wwwroot/fa-brands-400.eot +/API/wwwroot/fa-brands-400.svg +/API/wwwroot/fa-brands-400.ttf +/API/wwwroot/fa-brands-400.woff +/API/wwwroot/fa-brands-400.woff2 +/API/wwwroot/fa-regular-400.eot +/API/wwwroot/fa-regular-400.svg +/API/wwwroot/fa-regular-400.ttf +/API/wwwroot/fa-regular-400.woff +/API/wwwroot/fa-regular-400.woff2 +/API/wwwroot/fa-solid-900.eot +/API/wwwroot/fa-solid-900.svg +/API/wwwroot/fa-solid-900.ttf +/API/wwwroot/fa-solid-900.woff +/API/wwwroot/fa-solid-900.woff2 +/API/wwwroot/favicon.ico +/API/wwwroot/index.html +/API/wwwroot/main.js +/API/wwwroot/main.js.map +/API/wwwroot/polyfills.js +/API/wwwroot/polyfills.js.map +/API/wwwroot/runtime.js +/API/wwwroot/runtime.js.map +/API/wwwroot/styles.css +/API/wwwroot/styles.css.map +/API/wwwroot/vendor.js +/API/wwwroot/vendor.js.map diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs new file mode 100644 index 0000000000..74ff829997 --- /dev/null +++ b/API/Controllers/FallbackController.cs @@ -0,0 +1,14 @@ +using System.IO; +using API.Services; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers +{ + public class FallbackController : Controller + { + public ActionResult Index() + { + return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML"); + } + } +} \ No newline at end of file diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index af2d36bb66..445a28107c 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.Entities; using API.Extensions; using API.Interfaces; using Microsoft.AspNetCore.Authorization; @@ -26,7 +27,8 @@ public SeriesController(ILogger logger, ITaskScheduler taskSch [HttpGet("{seriesId}")] public async Task> GetSeries(int seriesId) { - return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId)); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id)); } [Authorize(Policy = "RequireAdminRole")] @@ -55,7 +57,29 @@ public async Task>> GetVolumes(int seriesId) [HttpGet("volume")] public async Task> GetVolume(int volumeId) { - return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId)); + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id)); + } + + [HttpPost("update-rating")] + public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) + { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + var userRating = await _unitOfWork.UserRepository.GetUserRating(updateSeriesRatingDto.SeriesId, user.Id) ?? + new AppUserRating(); + + userRating.Rating = updateSeriesRatingDto.UserRating; + userRating.Review = updateSeriesRatingDto.UserReview; + userRating.SeriesId = updateSeriesRatingDto.SeriesId; + + _unitOfWork.UserRepository.AddRatingTracking(userRating); + user.Ratings ??= new List(); + user.Ratings.Add(userRating); + + + if (!await _unitOfWork.Complete()) return BadRequest("There was a critical error."); + + return Ok(); } } } \ No newline at end of file diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 50160ab5c8..bf9fc8a45a 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -13,5 +13,13 @@ public class SeriesDto /// Sum of pages read from linked Volumes. Calculated at API-time. /// public int PagesRead { get; set; } + /// + /// Rating from logged in user. Calculated at API-time. + /// + public int UserRating { get; set; } + /// + /// Review from logged in user. Calculated at API-time. + /// + public string UserReview { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/UpdateSeriesRatingDto.cs b/API/DTOs/UpdateSeriesRatingDto.cs new file mode 100644 index 0000000000..d8b8dac2df --- /dev/null +++ b/API/DTOs/UpdateSeriesRatingDto.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs +{ + public class UpdateSeriesRatingDto + { + public int SeriesId { get; init; } + public int UserRating { get; init; } + [MaxLength(1000)] + public string UserReview { get; init; } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 2bb9424c0c..a8567849a9 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -23,6 +23,7 @@ public DataContext(DbContextOptions options) : base(options) public DbSet Volume { get; set; } public DbSet AppUser { get; set; } public DbSet AppUserProgresses { get; set; } + public DbSet AppUserRating { get; set; } protected override void OnModelCreating(ModelBuilder builder) { diff --git a/API/Data/Migrations/20210119213837_AppUserRatingAndReviews.Designer.cs b/API/Data/Migrations/20210119213837_AppUserRatingAndReviews.Designer.cs new file mode 100644 index 0000000000..e68e9e11ba --- /dev/null +++ b/API/Data/Migrations/20210119213837_AppUserRatingAndReviews.Designer.cs @@ -0,0 +1,603 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210119213837_AppUserRatingAndReviews")] + partial class AppUserRatingAndReviews + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210119213837_AppUserRatingAndReviews.cs b/API/Data/Migrations/20210119213837_AppUserRatingAndReviews.cs new file mode 100644 index 0000000000..98db3af2bf --- /dev/null +++ b/API/Data/Migrations/20210119213837_AppUserRatingAndReviews.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class AppUserRatingAndReviews : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AppUserRating", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Rating = table.Column(type: "INTEGER", nullable: false), + Review = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUserRating", x => x.Id); + table.ForeignKey( + name: "FK_AppUserRating_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserRating_AppUserId", + table: "AppUserRating", + column: "AppUserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppUserRating"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 7d345a8700..cbb687e1d3 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -143,6 +143,31 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AppUserProgresses"); }); + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.Property("UserId") @@ -415,6 +440,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("AppUser"); }); + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + modelBuilder.Entity("API.Entities.AppUserRole", b => { b.HasOne("API.Entities.AppRole", "Role") @@ -538,6 +574,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Progresses"); + b.Navigation("Ratings"); + b.Navigation("UserRoles"); }); diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index 510a213883..a8a5c1f98b 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -70,17 +70,9 @@ public async Task> GetSeriesDtoForLibraryIdAsync(int libr .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - if (userId > 0) - { - var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId)) - .ToListAsync(); - - foreach (var s in series) - { - s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead); - } - } + + await AddSeriesModifiers(userId, series); + Console.WriteLine("Processed GetSeriesDtoForLibraryIdAsync in {0} milliseconds", sw.ElapsedMilliseconds); return series; @@ -94,20 +86,14 @@ public async Task> GetVolumesDtoAsync(int seriesId, int u .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() .ToListAsync(); - var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId)) - .AsNoTracking() - .ToListAsync(); - - foreach (var v in volumes) - { - v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); - } + + await AddVolumeModifiers(userId, volumes); return volumes; } + public IEnumerable GetVolumes(int seriesId) { return _context.Volume @@ -117,10 +103,16 @@ public IEnumerable GetVolumes(int seriesId) .ToList(); } - public async Task GetSeriesDtoByIdAsync(int seriesId) + public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) { - return await _context.Series.Where(x => x.Id == seriesId) - .ProjectTo(_mapper.ConfigurationProvider).SingleAsync(); + var series = await _context.Series.Where(x => x.Id == seriesId) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleAsync(); + + var seriesList = new List() {series}; + await AddSeriesModifiers(userId, seriesList); + + return seriesList[0]; } public async Task GetVolumeAsync(int volumeId) @@ -130,13 +122,18 @@ public async Task GetVolumeAsync(int volumeId) .SingleOrDefaultAsync(vol => vol.Id == volumeId); } - public async Task GetVolumeDtoAsync(int volumeId) + public async Task GetVolumeDtoAsync(int volumeId, int userId) { - return await _context.Volume + var volume = await _context.Volume .Where(vol => vol.Id == volumeId) .Include(vol => vol.Files) .ProjectTo(_mapper.ConfigurationProvider) .SingleAsync(vol => vol.Id == volumeId); + + var volumeList = new List() {volume}; + await AddVolumeModifiers(userId, volumeList); + + return volumeList[0]; } /// @@ -163,5 +160,37 @@ public async Task GetVolumeByIdAsync(int volumeId) { return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); } + + private async Task AddSeriesModifiers(int userId, List series) + { + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && series.Select(s => s.Id).Contains(p.SeriesId)) + .ToListAsync(); + + var userRatings = await _context.AppUserRating + .Where(r => r.AppUserId == userId && series.Select(s => s.Id).Contains(r.SeriesId)) + .ToListAsync(); + + foreach (var s in series) + { + s.PagesRead = userProgress.Where(p => p.SeriesId == s.Id).Sum(p => p.PagesRead); + var rating = userRatings.SingleOrDefault(r => r.SeriesId == s.Id); + if (rating == null) continue; + s.UserRating = rating.Rating; + s.UserReview = rating.Review; + } + } + private async Task AddVolumeModifiers(int userId, List volumes) + { + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && volumes.Select(s => s.Id).Contains(p.VolumeId)) + .AsNoTracking() + .ToListAsync(); + + foreach (var v in volumes) + { + v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); + } + } } } \ No newline at end of file diff --git a/API/Data/UserRepository.cs b/API/Data/UserRepository.cs index 2569e16042..a06be47ee3 100644 --- a/API/Data/UserRepository.cs +++ b/API/Data/UserRepository.cs @@ -48,6 +48,17 @@ public async Task> GetAdminUsersAsync() return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); } + public async Task GetUserRating(int seriesId, int userId) + { + return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId) + .SingleOrDefaultAsync(); + } + + public void AddRatingTracking(AppUserRating userRating) + { + _context.AppUserRating.Add(userRating); + } + public async Task> GetMembersAsync() { return await _context.Users diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 10bcec5035..3384df9ee2 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -14,6 +14,7 @@ public class AppUser : IdentityUser, IHasConcurrencyToken public ICollection Libraries { get; set; } public ICollection UserRoles { get; set; } public ICollection Progresses { get; set; } + public ICollection Ratings { get; set; } [ConcurrencyCheck] public uint RowVersion { get; set; } diff --git a/API/Entities/AppUserRating.cs b/API/Entities/AppUserRating.cs new file mode 100644 index 0000000000..ca176e7ae0 --- /dev/null +++ b/API/Entities/AppUserRating.cs @@ -0,0 +1,22 @@ + +namespace API.Entities +{ + public class AppUserRating + { + public int Id { get; set; } + /// + /// A number between 0-5 that represents how good a series is. + /// + public int Rating { get; set; } + /// + /// A short summary the user can write when giving their review. + /// + public string Review { get; set; } + public int SeriesId { get; set; } + + + // Relationships + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } + } +} \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index a33fc18aaf..d3be26a602 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -15,10 +15,10 @@ public interface ISeriesRepository Task> GetSeriesForLibraryIdAsync(int libraryId); Task> GetVolumesDtoAsync(int seriesId, int userId); IEnumerable GetVolumes(int seriesId); - Task GetSeriesDtoByIdAsync(int seriesId); + Task GetSeriesDtoByIdAsync(int seriesId, int userId); Task GetVolumeAsync(int volumeId); - Task GetVolumeDtoAsync(int volumeId); + Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(int[] seriesIds); Task DeleteSeriesAsync(int seriesId); diff --git a/API/Interfaces/IUserRepository.cs b/API/Interfaces/IUserRepository.cs index 01127050a9..20f5153507 100644 --- a/API/Interfaces/IUserRepository.cs +++ b/API/Interfaces/IUserRepository.cs @@ -12,5 +12,7 @@ public interface IUserRepository Task GetUserByUsernameAsync(string username); Task> GetMembersAsync(); Task> GetAdminUsersAsync(); + Task GetUserRating(int seriesId, int userId); + void AddRatingTracking(AppUserRating userRating); } } \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index 90a4aff923..aaa2dae494 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -58,10 +58,14 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo app.UseAuthorization(); + app.UseDefaultFiles(); + app.UseStaticFiles(); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); endpoints.MapHangfireDashboard(); + endpoints.MapFallbackToController("Index", "Fallback"); }); } } diff --git a/README.md b/README.md index 989e151d1e..78693433b8 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,9 @@ open source variant that is flexible and packs more punch, without sacrificing e * Metadata should allow for collections, want to read integration from 3rd party services, genres. * Expose an OPDS API/Stream for external readers to use * Allow downloading files directly from WebApp -* WebApp/Server is Translated via Weblate (free for Open Source) \ No newline at end of file +* WebApp/Server is Translated via Weblate (free for Open Source) + + +## How to Deploy +* Build kavita-webui via ng build --prod. The dest should be placed in the API/wwwroot directory +* Run publish command \ No newline at end of file