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 @@
-
+
-