From 089658e4698dd604b99e153cf2b8580a8c5df37d Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 20 Nov 2022 14:32:21 -0600 Subject: [PATCH] UX Alignment and bugfixes (#1663) * Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter. Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop. * Removed a bug marker that I just fixed * When generating library covers, make them much smaller as they are only ever icons. * Fixed library settings not showing the correct image. * Fixed a bug where duplicate collection tags could be created. Fixed a bug where collection tag normalized title was being set to uppercase. Redesigned the edit collection tag modal to align with new library settings and provide inline name checks. * Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names. Don't show Continue point on series detail if the whole series is read. * Added some more unit tests around continue point * Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab. * Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting. Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build). * Test GA * Reverted GA and instead do it in the build step. This will just force developers to commit it in. * GA please work * Removed redundant steps from test since build already does it. * Try another GA * Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes. * Fixed env variable * Okay not possible to do secrets in if statement * Fixed the build step to output the openapi.json where it's expected. --- .github/workflows/sonar-scan.yml | 43 +- API.Tests/Services/ReaderServiceTests.cs | 66 + API/API.csproj | 6 +- API/Controllers/CollectionController.cs | 24 +- API/Controllers/LibraryController.cs | 6 +- API/Controllers/ReadingListController.cs | 38 +- API/Controllers/UploadController.cs | 3 +- API/DTOs/ReadingLists/ReadingListItemDto.cs | 7 +- API/DTOs/Settings/ServerSettingDTO.cs | 4 +- .../Repositories/CollectionTagRepository.cs | 8 + API/Data/Repositories/LibraryRepository.cs | 2 +- .../Repositories/ReadingListRepository.cs | 17 +- API/Entities/Enums/ServerSettingKey.cs | 4 +- API/Services/ImageService.cs | 10 +- API/Startup.cs | 48 +- CONTRIBUTING.md | 9 +- UI/Web/src/app/_models/reading-list.ts | 1 + .../app/_services/collection-tag.service.ts | 4 + .../src/app/_services/reading-list.service.ts | 4 + .../edit-collection-tags.component.html | 102 +- .../edit-collection-tags.component.scss | 3 + .../edit-collection-tags.component.ts | 67 +- .../cards/card-item/card-item.component.scss | 3 +- .../cards/list-item/list-item.component.html | 2 +- .../cards/list-item/list-item.component.scss | 7 +- .../all-collections.component.ts | 2 +- .../edit-reading-list-modal.component.html | 55 +- .../edit-reading-list-modal.component.scss | 3 + .../edit-reading-list-modal.component.ts | 65 +- .../draggable-ordered-list.component.html | 3 +- .../draggable-ordered-list.component.ts | 4 + .../reading-list-detail.component.html | 6 +- .../reading-list-detail.component.ts | 10 +- .../reading-list-item.component.html | 64 +- .../reading-list-item.component.scss | 25 + .../reading-list-item.component.ts | 2 + .../app/reading-list/reading-list.module.ts | 4 +- .../reading-lists.component.html | 2 +- .../reading-lists/reading-lists.component.ts | 3 + .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 7 +- .../library-settings-modal.component.ts | 23 +- UI/Web/src/theme/themes/dark.scss | 1 + openapi.json | 13362 ++++++++++++++++ 44 files changed, 13878 insertions(+), 253 deletions(-) create mode 100644 openapi.json diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index e0f98f393..d3a885b2e 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -22,6 +22,10 @@ jobs: with: dotnet-version: 6.0.x + - name: Install Swashbuckle CLI + shell: powershell + run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli + - name: Install dependencies run: dotnet restore @@ -35,29 +39,6 @@ jobs: name: csproj path: Kavita.Common/Kavita.Common.csproj - test: - name: Install Sonar & Test - needs: build - runs-on: windows-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Setup .NET Core - uses: actions/setup-dotnet@v2 - with: - dotnet-version: 6.0.x - - - name: Install dependencies - run: dotnet restore - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 1.11 - - name: Cache SonarCloud packages uses: actions/cache@v1 with: @@ -93,9 +74,10 @@ jobs: - name: Test run: dotnet test --no-restore --verbosity normal + version: name: Bump version on Develop push - needs: [ build, test ] + needs: [ build ] runs-on: ubuntu-latest if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} steps: @@ -108,6 +90,10 @@ jobs: with: dotnet-version: 6.0.x + - name: Install Swashbuckle CLI + shell: powershell + run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli + - name: Install dependencies run: dotnet restore @@ -194,6 +180,11 @@ jobs: uses: actions/setup-dotnet@v2 with: dotnet-version: 6.0.x + + - name: Install Swashbuckle CLI + shell: powershell + run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli + - run: ./monorepo-build.sh - name: Login to Docker Hub @@ -307,6 +298,10 @@ jobs: uses: actions/setup-dotnet@v2 with: dotnet-version: 6.0.x + - name: Install Swashbuckle CLI + shell: powershell + run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli + - run: ./monorepo-build.sh - name: Login to Docker Hub diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 71ecc1543..7c1011a69 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1734,6 +1734,72 @@ public class ReaderServiceTests Assert.Equal("1", nextChapter.Range); } + [Fact] + public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesAndAFewLooseChaptersRead() + { + _context.Series.Add(new Series() + { + Name = "Test", + Library = new Library() { + Name = "Test LIb", + Type = LibraryType.Manga, + }, + Volumes = new List() + { + EntityFactory.CreateVolume("0", new List() + { + EntityFactory.CreateChapter("100", false, new List(), 1), + EntityFactory.CreateChapter("101", false, new List(), 1), + EntityFactory.CreateChapter("102", false, new List(), 1), + }), + EntityFactory.CreateVolume("1", new List() + { + EntityFactory.CreateChapter("1", false, new List(), 1), + EntityFactory.CreateChapter("2", false, new List(), 1), + }), + EntityFactory.CreateVolume("2", new List() + { + EntityFactory.CreateChapter("21", false, new List(), 1), + }), + } + }); + + var user = new AppUser() + { + UserName = "majora2007" + }; + _context.AppUser.Add(user); + + await _context.SaveChangesAsync(); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>(), Substitute.For()); + + // Mark everything but chapter 101 as read + await readerService.MarkSeriesAsRead(user, 1); + await _unitOfWork.CommitAsync(); + + // Unmark last chapter as read + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 0, + ChapterId = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1)).Chapters.ElementAt(1).Id, + SeriesId = 1, + VolumeId = 1 + }, 1); + await readerService.SaveReadingProgress(new ProgressDto() + { + PageNum = 0, + ChapterId = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1)).Chapters.ElementAt(2).Id, + SeriesId = 1, + VolumeId = 1 + }, 1); + await _context.SaveChangesAsync(); + + var nextChapter = await readerService.GetContinuePoint(1, 1); + + Assert.Equal("101", nextChapter.Range); + } + [Fact] public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead() { diff --git a/API/API.csproj b/API/API.csproj index 1310f09a4..652e37a6c 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -5,10 +5,13 @@ net6.0 true Linux - true true true + + + + false @@ -91,6 +94,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 33bde22b6..1bdca14ea 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -9,7 +9,6 @@ using API.Extensions; using API.SignalR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; namespace API.Controllers; @@ -63,6 +62,19 @@ public class CollectionController : BaseApiController return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, user.Id); } + /// + /// Checks if a collection exists with the name + /// + /// If empty or null, will return true as that is invalid + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("name-exists")] + public async Task> DoesNameExists(string name) + { + if (string.IsNullOrEmpty(name.Trim())) return Ok(true); + return Ok(await _unitOfWork.CollectionTagRepository.TagExists(name)); + } + /// /// Updates an existing tag with a new title, promotion status, and summary. /// UI does not contain controls to update title @@ -71,14 +83,18 @@ public class CollectionController : BaseApiController /// [Authorize(Policy = "RequireAdminRole")] [HttpPost("update")] - public async Task UpdateTagPromotion(CollectionTagDto updatedTag) + public async Task UpdateTag(CollectionTagDto updatedTag) { var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id); if (existingTag == null) return BadRequest("This tag does not exist"); + var title = updatedTag.Title.Trim(); + if (string.IsNullOrEmpty(title)) return BadRequest("Title cannot be empty"); + if (!title.Equals(existingTag.Title) && await _unitOfWork.CollectionTagRepository.TagExists(updatedTag.Title)) + return BadRequest("A tag with this name already exists"); + existingTag.Title = title; existingTag.Promoted = updatedTag.Promoted; - existingTag.Title = updatedTag.Title.Trim(); - existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title).ToUpper(); + existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title); existingTag.Summary = updatedTag.Summary.Trim(); if (_unitOfWork.HasChanges()) diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 394fd7fa3..b8655322f 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -298,13 +298,15 @@ public class LibraryController : BaseApiController /// /// Checks if the library name exists or not /// - /// + /// If empty or null, will return true as that is invalid /// [Authorize(Policy = "RequireAdminRole")] [HttpGet("name-exists")] public async Task> IsLibraryNameValid(string name) { - return Ok(await _unitOfWork.LibraryRepository.LibraryExists(name.Trim())); + var trimmed = name.Trim(); + if (string.IsNullOrEmpty(trimmed)) return Ok(true); + return Ok(await _unitOfWork.LibraryRepository.LibraryExists(trimmed)); } /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index b6ee2724d..72678780c 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -218,22 +218,15 @@ public class ReadingListController : BaseApiController } dto.Title = dto.Title.Trim(); - if (!string.IsNullOrEmpty(dto.Title)) - { - readingList.Summary = dto.Summary; - if (!readingList.Title.Equals(dto.Title)) - { - var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); - if (hasExisting) - { - return BadRequest("A list of this name already exists"); - } - readingList.Title = dto.Title; - readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); - } - } + if (string.IsNullOrEmpty(dto.Title)) return BadRequest("Title must be set"); + if (!dto.Title.Equals(readingList.Title) && await _unitOfWork.ReadingListRepository.ReadingListExists(dto.Title)) + return BadRequest("Reading list already exists"); + + readingList.Summary = dto.Summary; + readingList.Title = dto.Title; + readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); readingList.Promoted = dto.Promoted; readingList.CoverImageLocked = dto.CoverImageLocked; @@ -246,10 +239,10 @@ public class ReadingListController : BaseApiController _unitOfWork.ReadingListRepository.Update(readingList); } - - _unitOfWork.ReadingListRepository.Update(readingList); + if (!_unitOfWork.HasChanges()) return Ok("Updated"); + if (await _unitOfWork.CommitAsync()) { return Ok("Updated"); @@ -498,4 +491,17 @@ public class ReadingListController : BaseApiController return Ok(-1); } + + /// + /// Checks if a reading list exists with the name + /// + /// If empty or null, will return true as that is invalid + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("name-exists")] + public async Task> DoesNameExists(string name) + { + if (string.IsNullOrEmpty(name)) return true; + return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name)); + } } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index dc4986910..3a52866e0 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -297,7 +297,8 @@ public class UploadController : BaseApiController try { - var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}"); + var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, + $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", ImageService.LibraryThumbnailWidth); if (!string.IsNullOrEmpty(filePath)) { diff --git a/API/DTOs/ReadingLists/ReadingListItemDto.cs b/API/DTOs/ReadingLists/ReadingListItemDto.cs index 39f844d8b..89be1d351 100644 --- a/API/DTOs/ReadingLists/ReadingListItemDto.cs +++ b/API/DTOs/ReadingLists/ReadingListItemDto.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using System; +using API.Entities.Enums; namespace API.DTOs.ReadingLists; @@ -18,6 +19,10 @@ public class ReadingListItemDto public int LibraryId { get; set; } public string Title { get; set; } /// + /// Release Date from Chapter + /// + public DateTime ReleaseDate { get; set; } + /// /// Used internally only /// public int ReadingListId { get; set; } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 332d06a69..9084e52e7 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using API.Services; namespace API.DTOs.Settings; @@ -50,6 +51,7 @@ public class ServerSettingDto /// /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. /// + [Obsolete("Being removed in v0.7 in favor of dedicated hosted api")] public bool EnableSwaggerUi { get; set; } /// /// The amount of Backups before cleanup diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index e4fcf5e50..1c86d4826 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -33,6 +33,7 @@ public interface ICollectionTagRepository Task RemoveTagsWithoutSeries(); Task> GetAllTagsAsync(); Task> GetAllCoverImagesAsync(); + Task TagExists(string title); } public class CollectionTagRepository : ICollectionTagRepository { @@ -101,6 +102,13 @@ public class CollectionTagRepository : ICollectionTagRepository .ToListAsync(); } + public async Task TagExists(string title) + { + var normalized = Services.Tasks.Scanner.Parser.Parser.Normalize(title); + return await _context.CollectionTag + .AnyAsync(x => x.NormalizedTitle.Equals(normalized)); + } + public async Task> GetAllTagDtosAsync() { diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 39ab5999e..04687c9f7 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -283,7 +283,7 @@ public class LibraryRepository : ILibraryRepository { return await _context.Library .AsNoTracking() - .AnyAsync(x => x.Name == libraryName); + .AnyAsync(x => x.Name.Equals(libraryName)); } public async Task> GetLibrariesForUserAsync(AppUser user) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 3401205d1..327a470fe 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -19,7 +19,6 @@ public interface IReadingListRepository Task> AddReadingProgressModifiers(int userId, IList items); Task GetReadingListDtoByTitleAsync(int userId, string title); Task> GetReadingListItemsByIdAsync(int readingListId); - Task> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId, bool includePromoted); void Remove(ReadingListItem item); @@ -29,6 +28,7 @@ public interface IReadingListRepository Task Count(); Task GetCoverImageAsync(int readingListId); Task> GetAllCoverImagesAsync(); + Task ReadingListExists(string name); } public class ReadingListRepository : IReadingListRepository @@ -75,6 +75,13 @@ public class ReadingListRepository : IReadingListRepository .ToListAsync(); } + public async Task ReadingListExists(string name) + { + var normalized = Services.Tasks.Scanner.Parser.Parser.Normalize(name); + return await _context.ReadingList + .AnyAsync(x => x.NormalizedTitle.Equals(normalized)); + } + public void Remove(ReadingListItem item) { _context.ReadingListItem.Remove(item); @@ -137,6 +144,7 @@ public class ReadingListRepository : IReadingListRepository { TotalPages = chapter.Pages, ChapterNumber = chapter.Range, + ReleaseDate = chapter.ReleaseDate, readingListItem = data }) .Join(_context.Volume, s => s.readingListItem.VolumeId, volume => volume.Id, (data, volume) => new @@ -144,6 +152,7 @@ public class ReadingListRepository : IReadingListRepository data.readingListItem, data.TotalPages, data.ChapterNumber, + data.ReleaseDate, VolumeId = volume.Id, VolumeNumber = volume.Name, }) @@ -157,7 +166,8 @@ public class ReadingListRepository : IReadingListRepository data.TotalPages, data.ChapterNumber, data.VolumeNumber, - data.VolumeId + data.VolumeId, + data.ReleaseDate, }) .Select(data => new ReadingListItemDto() { @@ -172,7 +182,8 @@ public class ReadingListRepository : IReadingListRepository VolumeNumber = data.VolumeNumber, LibraryId = data.LibraryId, VolumeId = data.VolumeId, - ReadingListId = data.readingListItem.ReadingListId + ReadingListId = data.readingListItem.ReadingListId, + ReleaseDate = data.ReleaseDate }) .Where(o => userLibraries.Contains(o.LibraryId)) .OrderBy(rli => rli.Order) diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 604d286ff..5c4fbd601 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -1,4 +1,5 @@ -using System.ComponentModel; +using System; +using System.ComponentModel; namespace API.Entities.Enums; @@ -85,6 +86,7 @@ public enum ServerSettingKey /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. /// [Description("EnableSwaggerUi")] + [Obsolete("Being removed in v0.7 in favor of dedicated hosted api")] EnableSwaggerUi = 15, /// /// Total Number of Backups to maintain before cleaning. Default 30, min 1. diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 9500b43ed..b9fc252f7 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -17,8 +17,9 @@ public interface IImageService /// /// base64 encoded image /// + /// Width of thumbnail /// File name with extension of the file. This will always write to - string CreateThumbnailFromBase64(string encodedImage, string fileName); + string CreateThumbnailFromBase64(string encodedImage, string fileName, int thumbnailWidth = 0); string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, bool saveAsWebP = false); /// @@ -46,6 +47,10 @@ public class ImageService : IImageService /// Width of the Thumbnail generation /// private const int ThumbnailWidth = 320; + /// + /// Width of a cover for Library + /// + public const int LibraryThumbnailWidth = 32; public ImageService(ILogger logger, IDirectoryService directoryService) { @@ -114,7 +119,6 @@ public class ImageService : IImageService var fileName = file.Name.Replace(file.Extension, string.Empty); var outputFile = Path.Join(outputPath, fileName + ".webp"); - using var sourceImage = await SixLabors.ImageSharp.Image.LoadAsync(filePath); await sourceImage.SaveAsWebpAsync(outputFile); return outputFile; @@ -139,7 +143,7 @@ public class ImageService : IImageService /// - public string CreateThumbnailFromBase64(string encodedImage, string fileName) + public string CreateThumbnailFromBase64(string encodedImage, string fileName, int thumbnailWidth = ThumbnailWidth) { try { diff --git a/API/Startup.cs b/API/Startup.cs index a22422d6a..55694a923 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Reflection; using System.Threading.Tasks; using API.Data; using API.Entities; @@ -103,15 +105,20 @@ public class Startup services.AddIdentityServices(_config); services.AddSwaggerGen(c => { - c.SwaggerDoc("v1", new OpenApiInfo() + c.SwaggerDoc("v1", new OpenApiInfo { + Version = BuildInfo.Version.ToString(), + Title = "Kavita", Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", - Title = "Kavita API", - Version = "v1", + License = new OpenApiLicense + { + Name = "GPL-3.0", + Url = new Uri("https://github.com/Kareadita/Kavita/blob/develop/LICENSE") + } }); - - var filePath = Path.Combine(AppContext.BaseDirectory, "API.xml"); + var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; + var filePath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(filePath, true); c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { In = ParameterLocation.Header, @@ -119,6 +126,7 @@ public class Startup Name = "Authorization", Type = SecuritySchemeType.ApiKey }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme @@ -133,30 +141,15 @@ public class Startup } }); - c.AddServer(new OpenApiServer() + c.AddServer(new OpenApiServer { - Description = "Custom Url", - Url = "/" + Url = "{protocol}://{hostpath}", + Variables = new Dictionary + { + { "protocol", new OpenApiServerVariable { Default = "http", Enum = new List { "http", "https" } } }, + { "hostpath", new OpenApiServerVariable { Default = "localhost:5000" } } + } }); - - c.AddServer(new OpenApiServer() - { - Description = "Local Server", - Url = "http://localhost:5000/", - }); - - c.AddServer(new OpenApiServer() - { - Url = "https://demo.kavitareader.com/", - Description = "Kavita Demo" - }); - - c.AddServer(new OpenApiServer() - { - Url = "http://" + GetLocalIpAddress() + ":5000/", - Description = "Local IP" - }); - }); services.AddResponseCompression(options => { @@ -256,6 +249,7 @@ public class Startup { c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version); }); + } }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e1fae0be..4d81e02a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,9 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit - Rider (optional to Visual Studio) (https://www.jetbrains.com/rider/) - HTML/Javascript editor of choice (VS Code/Sublime Text/Webstorm/Atom/etc) - [Git](https://git-scm.com/downloads) -- [NodeJS](https://nodejs.org/en/download/) (Node 14.X.X or higher) -- .NET 5.0+ +- [NodeJS](https://nodejs.org/en/download/) (Node 16.X.X or higher) +- .NET 6.0+ +- dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli ### Getting started ### @@ -47,8 +48,8 @@ Setup guides, FAQ, the more information we have on the [wiki](https://wiki.kavit - You're probably going to get some comments or questions from us, they will be to ensure consistency and maintainability - We'll try to respond to pull requests as soon as possible, if its been a day or two, please reach out to us, we may have missed it - Each PR should come from its own [feature branch](http://martinfowler.com/bliki/FeatureBranch.html) not develop in your fork, it should have a meaningful branch name (what is being added/fixed) - - new-feature (Good) - - fix-bug (Good) + - new-feature (Bad) + - fix-bug (Bad) - patch (Bad) - develop (Bad) - feature/parser-enhancements (Great) diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index 3a1dd7297..54ba3ec8a 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -12,6 +12,7 @@ export interface ReadingListItem { volumeNumber: string; libraryId: number; id: number; + releaseDate: string; } export interface ReadingList { diff --git a/UI/Web/src/app/_services/collection-tag.service.ts b/UI/Web/src/app/_services/collection-tag.service.ts index 6c58753c3..b53f4d5b7 100644 --- a/UI/Web/src/app/_services/collection-tag.service.ts +++ b/UI/Web/src/app/_services/collection-tag.service.ts @@ -36,4 +36,8 @@ export class CollectionTagService { addByMultiple(tagId: number, seriesIds: Array, tagTitle: string = '') { return this.httpClient.post(this.baseUrl + 'collection/update-for-series', {collectionTagId: tagId, collectionTagTitle: tagTitle, seriesIds}, {responseType: 'text' as 'json'}); } + + tagNameExists(name: string) { + return this.httpClient.get(this.baseUrl + 'collection/name-exists?name=' + name); + } } diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index dae0708b6..486c51f0f 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -87,4 +87,8 @@ export class ReadingListService { if (readingList?.promoted && !isAdmin) return false; return true; } + + nameExists(name: string) { + return this.httpClient.get(this.baseUrl + 'readinglist/name-exists?name=' + name); + } } diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html index 5d90fe9cd..ec8c1a170 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.html @@ -3,22 +3,50 @@ - diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.scss b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.scss index e69de29bb..3fd15f534 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.scss +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.scss @@ -0,0 +1,3 @@ +.form-switch { + margin-top: 2.4rem; +} \ No newline at end of file diff --git a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts index ee084dce4..b94427051 100644 --- a/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component.ts @@ -1,8 +1,8 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { forkJoin } from 'rxjs'; +import { debounceTime, distinctUntilChanged, forkJoin, Subject, switchMap, takeUntil, tap } from 'rxjs'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service'; import { SelectionModel } from 'src/app/typeahead/typeahead.component'; @@ -17,8 +17,9 @@ import { UploadService } from 'src/app/_services/upload.service'; enum TabID { - General = 0, - CoverImage = 1, + General = 'General', + CoverImage = 'Cover Image', + Series = 'Series' } @Component({ @@ -27,7 +28,7 @@ enum TabID { styleUrls: ['./edit-collection-tags.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) -export class EditCollectionTagsComponent implements OnInit { +export class EditCollectionTagsComponent implements OnInit, OnDestroy { @Input() tag!: CollectionTag; series: Array = []; @@ -38,11 +39,12 @@ export class EditCollectionTagsComponent implements OnInit { selectAll: boolean = true; libraryNames!: any; collectionTagForm!: FormGroup; - tabs = [{title: 'General', id: TabID.General}, {title: 'Cover Image', id: TabID.CoverImage}]; active = TabID.General; imageUrls: Array = []; selectedCover: string = ''; + private readonly onDestroy = new Subject(); + get hasSomeSelected() { return this.selections != null && this.selections.hasSomeSelected(); } @@ -66,15 +68,38 @@ export class EditCollectionTagsComponent implements OnInit { this.pagination = {totalPages: 1, totalItems: 200, itemsPerPage: 200, currentPage: 0}; } this.collectionTagForm = new FormGroup({ - summary: new FormControl(this.tag.summary, []), - coverImageLocked: new FormControl(this.tag.coverImageLocked, []), - coverImageIndex: new FormControl(0, []), - + title: new FormControl(this.tag.title, { nonNullable: true, validators: [Validators.required] }), + summary: new FormControl(this.tag.summary, { nonNullable: true, validators: [] }), + coverImageLocked: new FormControl(this.tag.coverImageLocked, { nonNullable: true, validators: [] }), + coverImageIndex: new FormControl(0, { nonNullable: true, validators: [] }), + promoted: new FormControl(this.tag.promoted, { nonNullable: true, validators: [] }), }); + + this.collectionTagForm.get('title')?.valueChanges.pipe( + debounceTime(100), + distinctUntilChanged(), + switchMap(name => this.collectionService.tagNameExists(name)), + tap(exists => { + const isExistingName = this.collectionTagForm.get('title')?.value === this.tag.title; + if (!exists || isExistingName) { + this.collectionTagForm.get('title')?.setErrors(null); + } else { + this.collectionTagForm.get('title')?.setErrors({duplicateName: true}) + } + this.cdRef.markForCheck(); + }), + takeUntil(this.onDestroy) + ).subscribe(); + this.imageUrls.push(this.imageService.randomize(this.imageService.getCollectionCoverImage(this.tag.id))); this.loadSeries(); } + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + onPageChange(pageNum: number) { this.pagination.currentPage = pageNum; this.loadSeries(); @@ -83,6 +108,7 @@ export class EditCollectionTagsComponent implements OnInit { toggleAll() { this.selectAll = !this.selectAll; this.series.forEach(s => this.selections.toggle(s, this.selectAll)); + this.cdRef.markForCheck(); } loadSeries() { @@ -91,9 +117,9 @@ export class EditCollectionTagsComponent implements OnInit { this.libraryService.getLibraryNames() ]).subscribe(results => { const series = results[0]; - this.pagination = series.pagination; this.series = series.result; + this.imageUrls.push(...this.series.map(s => this.imageService.getSeriesCoverImage(s.id))); this.selections = new SelectionModel(true, this.series); this.isLoading = false; @@ -114,18 +140,6 @@ export class EditCollectionTagsComponent implements OnInit { this.cdRef.markForCheck(); } - togglePromotion() { - const originalPromotion = this.tag.promoted; - this.tag.promoted = !this.tag.promoted; - this.cdRef.markForCheck(); - this.collectionService.updateTag(this.tag).subscribe(res => { - this.toastr.success('Tag updated successfully'); - }, err => { - this.tag.promoted = originalPromotion; - this.cdRef.markForCheck(); - }); - } - libraryName(libraryId: number) { return this.libraryNames[libraryId]; } @@ -140,12 +154,13 @@ export class EditCollectionTagsComponent implements OnInit { const tag: CollectionTag = {...this.tag}; tag.summary = this.collectionTagForm.get('summary')?.value; tag.coverImageLocked = this.collectionTagForm.get('coverImageLocked')?.value; + tag.promoted = this.collectionTagForm.get('promoted')?.value; if (unselectedIds.length == this.series.length && !await this.confirmSerivce.confirm('Warning! No series are selected, saving will delete the tag. Are you sure you want to continue?')) { return; } - const apis = [this.collectionService.updateTag(this.tag), + const apis = [this.collectionService.updateTag(tag), this.collectionService.updateSeriesForTag(tag, this.selections.unselected().map(s => s.id)) ]; @@ -153,7 +168,7 @@ export class EditCollectionTagsComponent implements OnInit { apis.push(this.uploadService.updateCollectionCoverImage(this.tag.id, this.selectedCover)); } - forkJoin(apis).subscribe(results => { + forkJoin(apis).subscribe(() => { this.modal.close({success: true, coverImageUpdated: selectedIndex > 0}); this.toastr.success('Tag updated'); }); diff --git a/UI/Web/src/app/cards/card-item/card-item.component.scss b/UI/Web/src/app/cards/card-item/card-item.component.scss index acaf835c4..cd3b100cf 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.scss +++ b/UI/Web/src/app/cards/card-item/card-item.component.scss @@ -1,6 +1,5 @@ -$triangle-size: 30px; $image-height: 230px; $image-width: 160px; @@ -75,7 +74,7 @@ $image-width: 160px; width: 0; height: 0; border-style: solid; - border-width: 0 $triangle-size $triangle-size 0; + border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0; border-color: transparent var(--primary-color) transparent transparent; } diff --git a/UI/Web/src/app/cards/list-item/list-item.component.html b/UI/Web/src/app/cards/list-item/list-item.component.html index bba132ad4..391acd274 100644 --- a/UI/Web/src/app/cards/list-item/list-item.component.html +++ b/UI/Web/src/app/cards/list-item/list-item.component.html @@ -11,7 +11,7 @@
-
+
-