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.
This commit is contained in:
parent
86fb2a8c94
commit
089658e469
44 changed files with 13878 additions and 253 deletions
|
@ -5,10 +5,13 @@
|
|||
<TargetFramework>net6.0</TargetFramework>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
<TieredCompilation>true</TieredCompilation>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
|
||||
<Exec Command="swagger tofile --output ../openapi.json bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).dll v1" />
|
||||
</Target>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
|
@ -91,6 +94,7 @@
|
|||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.24.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.2.3" />
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a collection exists with the name
|
||||
/// </summary>
|
||||
/// <param name="name">If empty or null, will return true as that is invalid</param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("name-exists")]
|
||||
public async Task<ActionResult<bool>> DoesNameExists(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name.Trim())) return Ok(true);
|
||||
return Ok(await _unitOfWork.CollectionTagRepository.TagExists(name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing tag with a new title, promotion status, and summary.
|
||||
/// <remarks>UI does not contain controls to update title</remarks>
|
||||
|
@ -71,14 +83,18 @@ public class CollectionController : BaseApiController
|
|||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> UpdateTagPromotion(CollectionTagDto updatedTag)
|
||||
public async Task<ActionResult> 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())
|
||||
|
|
|
@ -298,13 +298,15 @@ public class LibraryController : BaseApiController
|
|||
/// <summary>
|
||||
/// Checks if the library name exists or not
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <param name="name">If empty or null, will return true as that is invalid</param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("name-exists")]
|
||||
public async Task<ActionResult<bool>> 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a reading list exists with the name
|
||||
/// </summary>
|
||||
/// <param name="name">If empty or null, will return true as that is invalid</param>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("name-exists")]
|
||||
public async Task<ActionResult<bool>> DoesNameExists(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return true;
|
||||
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
{
|
||||
|
|
|
@ -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; }
|
||||
/// <summary>
|
||||
/// Release Date from Chapter
|
||||
/// </summary>
|
||||
public DateTime ReleaseDate { get; set; }
|
||||
/// <summary>
|
||||
/// Used internally only
|
||||
/// </summary>
|
||||
public int ReadingListId { get; set; }
|
||||
|
|
|
@ -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
|
|||
/// <summary>
|
||||
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
|
||||
/// </summary>
|
||||
[Obsolete("Being removed in v0.7 in favor of dedicated hosted api")]
|
||||
public bool EnableSwaggerUi { get; set; }
|
||||
/// <summary>
|
||||
/// The amount of Backups before cleanup
|
||||
|
|
|
@ -33,6 +33,7 @@ public interface ICollectionTagRepository
|
|||
Task<int> RemoveTagsWithoutSeries();
|
||||
Task<IEnumerable<CollectionTag>> GetAllTagsAsync();
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<bool> TagExists(string title);
|
||||
}
|
||||
public class CollectionTagRepository : ICollectionTagRepository
|
||||
{
|
||||
|
@ -101,6 +102,13 @@ public class CollectionTagRepository : ICollectionTagRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> 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<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
|
||||
{
|
||||
|
||||
|
|
|
@ -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<IEnumerable<LibraryDto>> GetLibrariesForUserAsync(AppUser user)
|
||||
|
|
|
@ -19,7 +19,6 @@ public interface IReadingListRepository
|
|||
Task<IEnumerable<ReadingListItemDto>> AddReadingProgressModifiers(int userId, IList<ReadingListItemDto> items);
|
||||
Task<ReadingListDto> GetReadingListDtoByTitleAsync(int userId, string title);
|
||||
Task<IEnumerable<ReadingListItem>> GetReadingListItemsByIdAsync(int readingListId);
|
||||
|
||||
Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int userId, int seriesId,
|
||||
bool includePromoted);
|
||||
void Remove(ReadingListItem item);
|
||||
|
@ -29,6 +28,7 @@ public interface IReadingListRepository
|
|||
Task<int> Count();
|
||||
Task<string> GetCoverImageAsync(int readingListId);
|
||||
Task<IList<string>> GetAllCoverImagesAsync();
|
||||
Task<bool> ReadingListExists(string name);
|
||||
}
|
||||
|
||||
public class ReadingListRepository : IReadingListRepository
|
||||
|
@ -75,6 +75,13 @@ public class ReadingListRepository : IReadingListRepository
|
|||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> 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)
|
||||
|
|
|
@ -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.
|
||||
/// </summary>
|
||||
[Description("EnableSwaggerUi")]
|
||||
[Obsolete("Being removed in v0.7 in favor of dedicated hosted api")]
|
||||
EnableSwaggerUi = 15,
|
||||
/// <summary>
|
||||
/// Total Number of Backups to maintain before cleaning. Default 30, min 1.
|
||||
|
|
|
@ -17,8 +17,9 @@ public interface IImageService
|
|||
/// </summary>
|
||||
/// <param name="encodedImage">base64 encoded image</param>
|
||||
/// <param name="fileName"></param>
|
||||
/// <param name="thumbnailWidth">Width of thumbnail</param>
|
||||
/// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
|
||||
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);
|
||||
/// <summary>
|
||||
|
@ -46,6 +47,10 @@ public class ImageService : IImageService
|
|||
/// Width of the Thumbnail generation
|
||||
/// </summary>
|
||||
private const int ThumbnailWidth = 320;
|
||||
/// <summary>
|
||||
/// Width of a cover for Library
|
||||
/// </summary>
|
||||
public const int LibraryThumbnailWidth = 32;
|
||||
|
||||
public ImageService(ILogger<ImageService> 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
|
|||
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName)
|
||||
public string CreateThumbnailFromBase64(string encodedImage, string fileName, int thumbnailWidth = ThumbnailWidth)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
|
@ -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<string, OpenApiServerVariable>
|
||||
{
|
||||
{ "protocol", new OpenApiServerVariable { Default = "http", Enum = new List<string> { "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);
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue