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:
Joe Milazzo 2022-11-20 14:32:21 -06:00 committed by GitHub
parent 86fb2a8c94
commit 089658e469
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 13878 additions and 253 deletions

View File

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

View File

@ -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<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("100", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("101", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("102", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
}),
}
});
var user = new AppUser()
{
UserName = "majora2007"
};
_context.AppUser.Add(user);
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
// 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()
{

View File

@ -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" />

View File

@ -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())

View File

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

View File

@ -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));
}
}

View File

@ -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))
{

View File

@ -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; }

View File

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

View File

@ -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()
{

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
});
}
});

View File

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

View File

@ -12,6 +12,7 @@ export interface ReadingListItem {
volumeNumber: string;
libraryId: number;
id: number;
releaseDate: string;
}
export interface ReadingList {

View File

@ -36,4 +36,8 @@ export class CollectionTagService {
addByMultiple(tagId: number, seriesIds: Array<number>, 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<boolean>(this.baseUrl + 'collection/name-exists?name=' + name);
}
}

View File

@ -87,4 +87,8 @@ export class ReadingListService {
if (readingList?.promoted && !isAdmin) return false;
return true;
}
nameExists(name: string) {
return this.httpClient.get<boolean>(this.baseUrl + 'readinglist/name-exists?name=' + name);
}
}

View File

@ -3,22 +3,50 @@
<h4 class="modal-title" id="modal-basic-title">Edit {{tag.title}} Collection</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[TabID.General].id">
<a ngbNavLink>{{tabs[TabID.General].title}}</a>
<ng-template ngbNavContent>
<p class="alert alert-secondary" role="alert">
This tag is currently {{tag.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.
</p>
<form [formGroup]="collectionTagForm">
<div class="mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{TabID.General}}</a>
<ng-template ngbNavContent>
<form [formGroup]="collectionTagForm">
<div class="row g-0 mb-3">
<div class="col-md-8 col-sm-12">
<label for="library-name" class="form-label">Name</label>
<input id="library-name" class="form-control" formControlName="title" type="text" [class.is-invalid]="collectionTagForm.get('title')?.invalid && collectionTagForm.get('title')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="collectionTagForm.dirty || collectionTagForm.touched">
<div *ngIf="collectionTagForm.get('title')?.errors?.required">
This field is required
</div>
<div *ngIf="collectionTagForm.get('title')?.errors?.duplicateName">
Name must be unique
</div>
</div>
</div>
</form>
<div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch">
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label me-1" for="tag-promoted">Promote</label>
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>
<ng-template #promotedTooltip>Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.</ng-template>
<span class="visually-hidden" id="tag-promoted-help"><ng-container [ngTemplateOutlet]="promotedTooltip"></ng-container></span>
</div>
</div>
</div>
<div class="row g-0 mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
</ng-template>
</li>
<li [ngbNavItem]="TabID.Series">
<a ngbNavLink>{{TabID.Series}}</a>
<ng-template ngbNavContent>
<div class="list-group" *ngIf="!isLoading">
<h6>Applies to Series</h6>
<div class="form-check">
@ -35,36 +63,30 @@
</div>
</li>
</ul>
</div>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
<div class="d-flex justify-content-center" *ngIf="pagination && series.length !== 0">
<ngb-pagination
*ngIf="pagination.totalPages > 1"
[(page)]="pagination.currentPage"
[pageSize]="pagination.itemsPerPage"
(pageChange)="onPageChange($event)"
[rotate]="false" [ellipses]="false" [boundaryLinks]="true"
[collectionSize]="pagination.totalItems"></ngb-pagination>
</div>
</div>
</ng-template>
</li>
<li [ngbNavItem]="tabs[TabID.CoverImage].id">
<a ngbNavLink>{{tabs[TabID.CoverImage].title}}</a>
<ng-template ngbNavContent>
<p class="alert alert-secondary" role="alert">
<!-- TODO: I don't think we need this anymore, it's a bit intuitive -->
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>
</li>
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{TabID.CoverImage}}</a>
<ng-template ngbNavContent>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="tag.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3 ms-2"></div>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-secondary alt" (click)="togglePromotion()">{{tag.promoted ? 'Demote' : 'Promote'}}</button>
<button type="button" class="btn btn-primary" (click)="save()">Save</button>
<button type="button" class="btn btn-primary" [disabled]="collectionTagForm.invalid" (click)="save()">Save</button>
</div>

View File

@ -0,0 +1,3 @@
.form-switch {
margin-top: 2.4rem;
}

View File

@ -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<Series> = [];
@ -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<string> = [];
selectedCover: string = '';
private readonly onDestroy = new Subject<void>();
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<Series>(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');
});

View File

@ -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;
}

View File

@ -11,7 +11,7 @@
</div>
<div class="flex-grow-1">
<div class="g-0">
<h5>
<h5 class="mb-0">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="seriesName" iconClass="fa-ellipsis-v"></app-card-actionables>
<ng-content select="[title]"></ng-content>
<button class="btn btn-primary float-end" (click)="read.emit()">

View File

@ -1,6 +1,5 @@
$image-height: 230px;
$image-width: 160px;
$triangle-size: 30px;
// with summary and cards, we have a height of 220px, we might want to default to 220px and let it grow from there to help with virtualization
@ -34,14 +33,10 @@ $triangle-size: 30px;
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;
}
h5 {
margin-bottom: 0;
}
.subtitle-with-actionables {
font-size: 0.75rem;
word-break: break-all;

View File

@ -67,7 +67,7 @@ export class AllCollectionsComponent implements OnInit, OnDestroy {
this.isLoading = true;
this.cdRef.markForCheck();
this.collectionService.allTags().subscribe(tags => {
this.collections = tags;
this.collections = [...tags];
this.isLoading = false;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(tags, (t: Tag) => t.title);
this.cdRef.markForCheck();

View File

@ -3,44 +3,57 @@
<h4 class="modal-title" id="modal-basic-title">Edit {{readingList.title}} Reading List</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="tabs[0]">
<a ngbNavLink>{{tabs[0].title}}</a>
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills"
orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
<li [ngbNavItem]="TabID.General">
<a ngbNavLink>{{TabID.General}}</a>
<ng-template ngbNavContent>
<p>
This list is currently {{readingList.promoted ? 'promoted' : 'not promoted'}} (<i class="fa fa-angle-double-up" aria-hidden="true"></i>).
Promotion means that the list can be seen server-wide, not just for admin users. All series that are within this list will still have user-access restrictions placed on them.
</p>
<form [formGroup]="reviewGroup">
<div class="mb-3">
<label for="title" class="form-label">Name</label>
<input id="title" class="form-control" formControlName="title" type="text">
<div class="row g-0 mb-3">
<div class="col-md-8 col-sm-12">
<label for="library-name" class="form-label">Name</label>
<input id="library-name" class="form-control" formControlName="title" type="text" [class.is-invalid]="reviewGroup.get('title')?.invalid && reviewGroup.get('title')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="reviewGroup.dirty || reviewGroup.touched">
<div *ngIf="reviewGroup.get('title')?.errors?.required">
This field is required
</div>
<div *ngIf="reviewGroup.get('title')?.errors?.duplicateName">
Name must be unique
</div>
</div>
</div>
<div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch">
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label me-1" for="tag-promoted">Promote</label>
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>
<ng-template #promotedTooltip>Promotion means that the tag can be seen server-wide, not just for admin users. All series that have this tag will still have user-access restrictions placed on them.</ng-template>
<span class="visually-hidden" id="tag-promoted-help"><ng-container [ngTemplateOutlet]="promotedTooltip"></ng-container></span>
</div>
</div>
</div>
<div class="mb-3">
<div class="row g-0 mb-3">
<label for="summary" class="form-label">Summary</label>
<textarea id="summary" class="form-control" formControlName="summary" rows="3"></textarea>
</div>
</form>
</ng-template>
</li>
<li [ngbNavItem]="tabs[1]">
<a ngbNavLink>{{tabs[1].title}}</a>
<li [ngbNavItem]="TabID.CoverImage">
<a ngbNavLink>{{TabID.CoverImage}}</a>
<ng-template ngbNavContent>
<p class="alert alert-primary" role="alert">
Upload and choose a new cover image. Press Save to upload and override the cover.
</p>
<app-cover-image-chooser [(imageUrls)]="imageUrls" (imageSelected)="updateSelectedIndex($event)" (selectedBase64Url)="updateSelectedImage($event)" [showReset]="readingList.coverImageLocked" (resetClicked)="handleReset()"></app-cover-image-chooser>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="ms-2 mt-3"></div>
<div [ngbNavOutlet]="nav" class="tab-content {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'mt-3' : 'ms-4 flex-fill'}}"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
<button type="button" class="btn btn-secondary alt" (click)="togglePromotion()">{{readingList.promoted ? 'Demote' : 'Promote'}}</button>
<button type="submit" class="btn btn-primary" [disabled]="reviewGroup.get('title')?.value.trim().length === 0" (click)="save()">Save</button>
<button type="submit" class="btn btn-primary" [disabled]="!reviewGroup.valid" (click)="save()">Save</button>
</div>

View File

@ -0,0 +1,3 @@
.form-switch {
margin-top: 2.4rem;
}

View File

@ -1,21 +1,26 @@
import { Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormControl, 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 { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { ReadingList } from 'src/app/_models/reading-list';
import { ImageService } from 'src/app/_services/image.service';
import { ReadingListService } from 'src/app/_services/reading-list.service';
import { UploadService } from 'src/app/_services/upload.service';
enum TabID {
General = 'General',
CoverImage = 'Cover Image'
}
@Component({
selector: 'app-edit-reading-list-modal',
templateUrl: './edit-reading-list-modal.component.html',
styleUrls: ['./edit-reading-list-modal.component.scss']
styleUrls: ['./edit-reading-list-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditReadingListModalComponent implements OnInit {
export class EditReadingListModalComponent implements OnInit, OnDestroy {
@Input() readingList!: ReadingList;
reviewGroup!: FormGroup;
@ -26,29 +31,49 @@ export class EditReadingListModalComponent implements OnInit {
*/
selectedCover: string = '';
coverImageLocked: boolean = false;
imageUrls: Array<string> = [];
active = TabID.General;
tabs = [{title: 'General', disabled: false}, {title: 'Cover', disabled: false}];
active = this.tabs[0];
private readonly onDestroy = new Subject<void>();
get Breakpoint() {
return Breakpoint;
}
get Breakpoint() { return Breakpoint; }
get TabID() { return TabID; }
constructor(private ngModal: NgbActiveModal, private readingListService: ReadingListService,
public utilityService: UtilityService, private uploadService: UploadService, private toastr: ToastrService,
private imageService: ImageService) { }
private imageService: ImageService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.reviewGroup = new FormGroup({
title: new FormControl(this.readingList.title, [Validators.required]),
summary: new FormControl(this.readingList.summary, [])
title: new FormControl(this.readingList.title, { nonNullable: true, validators: [Validators.required] }),
summary: new FormControl(this.readingList.summary, { nonNullable: true, validators: [] }),
promoted: new FormControl(this.readingList.promoted, { nonNullable: true, validators: [] }),
});
this.reviewGroup.get('title')?.valueChanges.pipe(
debounceTime(100),
distinctUntilChanged(),
switchMap(name => this.readingListService.nameExists(name)),
tap(exists => {
const isExistingName = this.reviewGroup.get('title')?.value === this.readingList.title;
if (!exists || isExistingName) {
this.reviewGroup.get('title')?.setErrors(null);
} else {
this.reviewGroup.get('title')?.setErrors({duplicateName: true})
}
this.cdRef.markForCheck();
}),
takeUntil(this.onDestroy)
).subscribe();
this.imageUrls.push(this.imageService.randomize(this.imageService.getReadingListCoverImage(this.readingList.id)));
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
close() {
this.ngModal.dismiss(undefined);
}
@ -56,7 +81,7 @@ export class EditReadingListModalComponent implements OnInit {
save() {
if (this.reviewGroup.value.title.trim() === '') return;
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted, coverImageLocked: this.coverImageLocked};
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, coverImageLocked: this.coverImageLocked};
const apis = [this.readingListService.update(model)];
if (this.selectedCover !== '') {
@ -67,22 +92,12 @@ export class EditReadingListModalComponent implements OnInit {
this.readingList.title = model.title;
this.readingList.summary = model.summary;
this.readingList.coverImageLocked = this.coverImageLocked;
this.readingList.promoted = model.promoted;
this.ngModal.close(this.readingList);
this.toastr.success('Reading List updated');
});
}
togglePromotion() {
const originalPromotion = this.readingList.promoted;
this.readingList.promoted = !this.readingList.promoted;
const model = {...this.reviewGroup.value, readingListId: this.readingList.id, promoted: this.readingList.promoted, coverImageLocked: this.coverImageLocked};
this.readingListService.update(model).subscribe(res => {
/* No Operation */
}, err => {
this.readingList.promoted = originalPromotion;
});
}
updateSelectedIndex(index: number) {
this.coverImageIndex = index;
}

View File

@ -1,5 +1,4 @@
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
<!-- BUG: https://github.com/angular/components/issues/14098 -->
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
<div class="d-flex list-container">
<div class="me-3 align-middle">
@ -12,7 +11,7 @@
<label for="reorder-{{i}}" class="form-label visually-hidden">Reorder</label>
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" min="0" [max]="items.length - 1" [value]="i" style="width: 60px" (focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
</div>
<button class="btn btn-icon float-end" (click)="removeItem(item, i)">
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton">
<i class="fa fa-times" aria-hidden="true"></i>
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">Remove item</span>
</button>

View File

@ -21,6 +21,10 @@ export interface ItemRemoveEvent {
export class DraggableOrderedListComponent implements OnInit {
@Input() accessibilityMode: boolean = false;
/**
* Shows the remove button on the list item
*/
@Input() showRemoveButton: boolean = true;
@Input() items: Array<any> = [];
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();

View File

@ -54,7 +54,7 @@
</div>
</div>
<div class="row mb-3">
<div class="row mb-3" cdkScrollable>
<div class="mx-auto" style="width: 200px;">
<ng-container *ngIf="items.length === 0 && !isLoading">
Nothing added
@ -67,10 +67,10 @@
</div>
<!-- TODO: This needs virtualization -->
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" (itemRemove)="itemRemoved($event)" [accessibilityMode]="accessibilityMode">
<app-draggable-ordered-list [items]="items" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode" [showRemoveButton]="false">
<ng-template #draggableItem let-item let-position="idx">
<app-reading-list-item class="content-container" [item]="item" [position]="position" [libraryTypes]="libraryTypes"
[promoted]="item.promoted" (read)="readChapter($event)"></app-reading-list-item>
[promoted]="item.promoted" (read)="readChapter($event)" (remove)="itemRemoved($event, position)"></app-reading-list-item>
</ng-template>
</app-draggable-ordered-list>
</div>

View File

@ -88,7 +88,8 @@ export class ReadingListDetailComponent implements OnInit {
this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadingRole = this.accountService.hasDownloadRole(user);
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)).filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this))
.filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin));
this.cdRef.markForCheck();
}
});
@ -153,10 +154,11 @@ export class ReadingListDetailComponent implements OnInit {
this.readingListService.updatePosition(this.readingList.id, event.item.id, event.fromPosition, event.toPosition).subscribe(() => { /* No Operation */ });
}
itemRemoved(event: ItemRemoveEvent) {
itemRemoved(item: ReadingListItem, position: number) {
if (!this.readingList) return;
this.readingListService.deleteItem(this.readingList.id, event.item.id).subscribe(() => {
this.items.splice(event.position, 1);
this.readingListService.deleteItem(this.readingList.id, item.id).subscribe(() => {
this.items.splice(position, 1);
this.items = [...this.items];
this.cdRef.markForCheck();
this.toastr.success('Item removed');
});

View File

@ -1,25 +1,45 @@
<div class="d-flex" style="width: 100%;">
<app-image width="74px" maxHeight="104px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1" id="item.id--{{position}}">
{{title}}&nbsp;
<!-- TODO: Create a read/unread badge -->
<span class="badge bg-primary rounded-pill">
<span *ngIf="item.pagesRead > 0 && item.pagesRead < item.pagesTotal">{{item.pagesRead}} / {{item.pagesTotal}}</span>
<span *ngIf="item.pagesRead === 0">UNREAD</span>
<span *ngIf="item.pagesRead === item.pagesTotal">READ</span>
</span>
</h5>
<ng-container *ngIf="item.seriesFormat | mangaFormat as formatString">
<i class="fa {{item.seriesFormat | mangaFormatIcon}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{formatString}}"></i>
<span class="visually-hidden">{{formatString}}</span>&nbsp;
</ng-container>
<div class="d-flex flex-row g-0 mb-2">
<div class="pe-2">
<app-image width="106px" maxHeight="125px" class="img-top me-3" [imageUrl]="imageService.getChapterCoverImage(item.chapterId)"></app-image>
<div class="not-read-badge" *ngIf="item.pagesRead === 0 && item.pagesTotal > 0"></div>
<div class="progress-banner" *ngIf="item.pagesRead < item.pagesTotal && item.pagesTotal > 0 && item.pagesRead !== item.pagesTotal">
<p><ngb-progressbar type="primary" height="5px" [value]="item.pagesRead" [max]="item.pagesTotal"></ngb-progressbar></p>
</div>
</div>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
<span *ngIf="promoted">
<i class="fa fa-angle-double-up" aria-hidden="true"></i>
</span>
<br/>
<a href="javascript:void(0);" (click)="readChapter(item)">Read</a>
<div class="flex-grow-1">
<div class="g-0">
<h5 class="mb-1 pb-0" id="item.id--{{position}}">
{{title}}
<div class="float-end">
<button class="btn btn-primary" (click)="readChapter(item)">
<span>
<i class="fa fa-book me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-sm-inline-block">Read</span>
</button>
<button class="btn btn-danger ms-2" (click)="remove.emit(item)">
<span>
<i class="fa fa-trash me-1" aria-hidden="true"></i>
</span>
<span class="d-none d-sm-inline-block">Remove</span>
</button>
</div>
</h5>
<div class="ps-1 d-none d-md-inline-block">
<ng-container *ngIf="item.seriesFormat | mangaFormat as formatString">
<i class="fa {{item.seriesFormat | mangaFormatIcon}}" aria-hidden="true" *ngIf="item.seriesFormat != MangaFormat.UNKNOWN" title="{{formatString}}"></i>
<span class="visually-hidden">{{formatString}}</span>&nbsp;
</ng-container>
<a href="/library/{{item.libraryId}}/series/{{item.seriesId}}">{{item.seriesName}}</a>
</div>
<div class="ps-1 mt-2" *ngIf="item.releaseDate !== '0001-01-01T00:00:00'">
Released: {{item.releaseDate | date:'short'}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,25 @@
.progress-banner {
height: 5px;
.progress {
color: var(--card-progress-bar-color);
background-color: transparent;
}
}
.list-item-container {
background: var(--card-list-item-bg-color);
border-radius: 5px;
position: relative;
}
.not-read-badge {
position: absolute;
top: 8px;
left: 108px;
width: 0;
height: 0;
border-style: solid;
border-width: 0 var(--card-progress-triangle-size) var(--card-progress-triangle-size) 0;
border-color: transparent var(--primary-color) transparent transparent;
}

View File

@ -22,6 +22,7 @@ export class ReadingListItemComponent implements OnInit {
@Input() promoted: boolean = false;
@Output() read: EventEmitter<ReadingListItem> = new EventEmitter();
@Output() remove: EventEmitter<ReadingListItem> = new EventEmitter();
title: string = '';
@ -65,4 +66,5 @@ export class ReadingListItemComponent implements OnInit {
this.read.emit(item);
}
}

View File

@ -10,7 +10,7 @@ import { ReadingListsComponent } from './reading-lists/reading-lists.component';
import { EditReadingListModalComponent } from './_modals/edit-reading-list-modal/edit-reading-list-modal.component';
import { PipeModule } from '../pipe/pipe.module';
import { SharedModule } from '../shared/shared.module';
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbNavModule, NgbProgressbarModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-nav-cards.module';
import { ReadingListItemComponent } from './reading-list-item/reading-list-item.component';
@ -30,6 +30,8 @@ import { ReadingListItemComponent } from './reading-list-item/reading-list-item.
ReactiveFormsModule,
DragDropModule,
NgbNavModule,
NgbProgressbarModule,
NgbTooltipModule,
PipeModule,
SharedModule,

View File

@ -13,7 +13,7 @@
[filteringDisabled]="true"
>
<ng-template #cardItem let-item let-position="idx" >
<app-card-item [title]="item.title" [entity]="item" [actions]="getActions(item)"
<app-card-item [title]="item.title" [entity]="item" [actions]="actions[item.id]"
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
(clicked)="handleClick(item)"></app-card-item>
</ng-template>

View File

@ -25,6 +25,7 @@ export class ReadingListsComponent implements OnInit {
pagination!: Pagination;
isAdmin: boolean = false;
jumpbarKeys: Array<JumpKey> = [];
actions: {[key: number]: Array<ActionItem<ReadingList>>} = {};
constructor(private readingListService: ReadingListService, public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private accountService: AccountService, private toastr: ToastrService, private router: Router, private actionService: ActionService,
@ -86,6 +87,8 @@ export class ReadingListsComponent implements OnInit {
this.pagination = readingLists.pagination;
this.jumpbarKeys = this.jumpbarService.getJumpKeys(readingLists.result, (rl: ReadingList) => rl.title);
this.loadingLists = false;
this.actions = {};
this.lists.forEach(l => this.actions[l.id] = this.getActions(l));
window.scrollTo(0, 0);
this.cdRef.markForCheck();
});

View File

@ -63,7 +63,7 @@
<app-tag-badge [selectionMode]="TagBadgeCursor.NotAllowed" fillStyle="filled">{{unreadCount}}</app-tag-badge>
</div>
<app-image height="100%" maxHeight="400px" objectFit="contain" background="none" [imageUrl]="seriesImage"></app-image>
<div class="under-image mt-1" *ngIf="hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
<div class="under-image mt-1" *ngIf="series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial">
Continue {{ContinuePointTitle}}
</div>
</div>

View File

@ -173,7 +173,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
let chapterArray = this.storyChapters;
if (this.activeTabId === TabID.Chapters) chapterArray = this.chapters;
const selectedChapterIds = chapterArray.filter((_chapter, index: number) => selectedChapterIndexes.includes(index + ''));
// We must augment chapter indecies as Bulk Selection assumes all on one page, but Storyline has mixed
const chapterIndexModifier = this.activeTabId === TabID.Storyline ? this.volumes.length + 1 : 0;
const selectedChapterIds = chapterArray.filter((_chapter, index: number) => {
const mappedIndex = index + chapterIndexModifier;
return selectedChapterIndexes.includes(mappedIndex + '');
});
const selectedVolumeIds = this.volumes.filter((_volume, index: number) => selectedVolumeIndexes.includes(index + ''));
const selectedSpecials = this.specials.filter((_chapter, index: number) => selectedSpecialIndexes.includes(index + ''));
const chapters = [...selectedChapterIds, ...selectedSpecials];

View File

@ -1,13 +1,14 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { debounceTime, distinctUntilChanged, filter, switchMap, tap } from 'rxjs';
import { debounceTime, distinctUntilChanged, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { SettingsService } from 'src/app/admin/settings.service';
import { DirectoryPickerComponent, DirectoryPickerResult } from 'src/app/admin/_modals/directory-picker/directory-picker.component';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { Breakpoint, UtilityService } from 'src/app/shared/_services/utility.service';
import { Library, LibraryType } from 'src/app/_models/library';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { UploadService } from 'src/app/_services/upload.service';
@ -31,7 +32,7 @@ enum StepID {
styleUrls: ['./library-settings-modal.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LibrarySettingsModalComponent implements OnInit {
export class LibrarySettingsModalComponent implements OnInit, OnDestroy {
@Input() library!: Library;
@ -53,6 +54,7 @@ export class LibrarySettingsModalComponent implements OnInit {
isAddLibrary = false;
setupStep = StepID.General;
private readonly onDestroy = new Subject<void>();
get Breakpoint() { return Breakpoint; }
get TabID() { return TabID; }
@ -60,7 +62,8 @@ export class LibrarySettingsModalComponent implements OnInit {
constructor(public utilityService: UtilityService, private uploadService: UploadService, private modalService: NgbModal,
private settingService: SettingsService, public modal: NgbActiveModal, private confirmService: ConfirmService,
private libraryService: LibraryService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
private libraryService: LibraryService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef,
private imageService: ImageService) { }
ngOnInit(): void {
@ -76,7 +79,7 @@ export class LibrarySettingsModalComponent implements OnInit {
}
if (this.library?.coverImage != null && this.library?.coverImage !== '') {
this.imageUrls.push(this.library.coverImage);
this.imageUrls.push(this.imageService.getLibraryCoverImage(this.library.id));
this.cdRef.markForCheck();
}
@ -86,20 +89,26 @@ export class LibrarySettingsModalComponent implements OnInit {
switchMap(name => this.libraryService.libraryNameExists(name)),
tap(exists => {
const isExistingName = this.libraryForm.get('name')?.value === this.library?.name;
console.log('isExistingName', isExistingName)
if (!exists || isExistingName) {
this.libraryForm.get('name')?.setErrors(null);
} else {
this.libraryForm.get('name')?.setErrors({duplicateName: true})
}
this.cdRef.markForCheck();
})
}),
takeUntil(this.onDestroy)
).subscribe();
this.setValues();
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
setValues() {
if (this.library !== undefined) {
this.libraryForm.get('name')?.setValue(this.library.name);

View File

@ -194,6 +194,7 @@
--card-progress-bar-color: var(--primary-color);
--card-overlay-bg-color: rgba(0, 0, 0, 0);
--card-overlay-hover-bg-color: rgba(0, 0, 0, 0.2);
--card-progress-triangle-size: 30px;
/* Slider */
--slider-text-color: white;

13362
openapi.json Normal file

File diff suppressed because it is too large Load Diff