mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Disable Animations + Lots of bugfixes and Polish (#1561)
* Fixed inputs not showing inline validation due to a missing class * Fixed some checks * Increased the button size on manga reader (develop) * Migrated a type cast to a pure pipe * Sped up the check for if SendTo should render on the menu * Don't allow user to bookmark in bookmark mode * Fixed a bug where Scan Series would skip over Specials due to how new scan loop works. * Fixed scroll to top button persisting when navigating between pages * Edit Series modal now doesn't have a lock field for Series, which can't be locked as it is inheritently locked. Added some validation to ensure Name and SortName are required. * Fixed up some spacing * Fixed actionable menu not opening submenu on mobile * Cleaned up the layout of cover image on series detail * Show all volume or chapters (if only one volume) for cover selection on series * Don't open submenu to right if there is no space * Fixed up cover image not allowing custom saves of existing series/chapter/volume images. Fixed up logging so console output matches log file. * Implemented the ability to turn off css transitions in the UI. * Updated a note internally * Code smells * Added InstallId when pinging the email service to allow throughput tracking
This commit is contained in:
parent
ee7d109170
commit
28ab34c66d
@ -91,7 +91,7 @@ public abstract class BasicTest
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
protected async Task ResetDB()
|
||||
protected async Task ResetDb()
|
||||
{
|
||||
_context.Series.RemoveRange(_context.Series.ToList());
|
||||
_context.Users.RemoveRange(_context.Users.ToList());
|
||||
|
@ -5,6 +5,7 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.Tests.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
@ -3,6 +3,7 @@ using System.IO.Abstractions.TestingHelpers;
|
||||
using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
@ -77,6 +78,21 @@ public class DefaultParserTests
|
||||
Assert.Equal(expectedParseInfo, actual.Series);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/manga/Btooom!/Specials/Art Book.cbz", "Btooom!")]
|
||||
public void ParseFromFallbackFolders_ShouldUseExistingSeriesName_NewScanLoop(string inputFile, string expectedParseInfo)
|
||||
{
|
||||
const string rootDirectory = "/manga/";
|
||||
var fs = new MockFileSystem();
|
||||
fs.AddDirectory(rootDirectory);
|
||||
fs.AddFile(inputFile, new MockFileData(""));
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
|
||||
var parser = new DefaultParser(ds);
|
||||
var actual = parser.Parse(inputFile, rootDirectory);
|
||||
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
|
||||
Assert.Equal(expectedParseInfo, actual.Series);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
|
@ -22,9 +22,10 @@ public class DeviceServiceTests : BasicTest
|
||||
_deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For<IEmailService>());
|
||||
}
|
||||
|
||||
protected void ResetDB()
|
||||
protected new Task ResetDb()
|
||||
{
|
||||
_context.Users.RemoveRange(_context.Users.ToList());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
|
@ -12,6 +12,7 @@ using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using API.SignalR;
|
||||
using API.Tests.Helpers;
|
||||
using AutoMapper;
|
||||
|
@ -49,8 +49,8 @@ public class UploadController : BaseApiController
|
||||
[HttpPost("upload-by-url")]
|
||||
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
|
||||
{
|
||||
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", "");
|
||||
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace('/', '_').Replace(':', '_');
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty);
|
||||
try
|
||||
{
|
||||
var path = await dto.Url
|
||||
|
@ -102,6 +102,7 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
|
||||
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
|
||||
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
|
||||
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
||||
|
||||
_unitOfWork.UserRepository.Update(existingPreferences);
|
||||
|
||||
|
@ -1,7 +1,4 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Data;
|
||||
using API.DTOs.Theme;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
@ -117,4 +114,9 @@ public class UserPreferencesDto
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool PromptForDownloadSize { get; set; } = true;
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Should Kavita disable CSS transitions
|
||||
/// </summary>
|
||||
[Required]
|
||||
public bool NoTransitions { get; set; } = false;
|
||||
}
|
||||
|
1661
API/Data/Migrations/20220926145902_AddNoTransitions.Designer.cs
generated
Normal file
1661
API/Data/Migrations/20220926145902_AddNoTransitions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
API/Data/Migrations/20220926145902_AddNoTransitions.cs
Normal file
26
API/Data/Migrations/20220926145902_AddNoTransitions.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class AddNoTransitions : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoTransitions",
|
||||
table: "AppUserPreferences",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoTransitions",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
@ -219,6 +219,9 @@ namespace API.Data.Migrations
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("NoTransitions")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PageSplitOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
@ -351,31 +351,6 @@ public class UserRepository : IUserRepository
|
||||
|| EF.Functions.Like(o.series.NormalizedName, $"%{seriesNameQueryNormalized}%")
|
||||
);
|
||||
|
||||
// This doesn't work on bookmarks themselves, only the series. For now, I don't think there is much value add
|
||||
// if (filter.SortOptions != null)
|
||||
// {
|
||||
// if (filter.SortOptions.IsAscending)
|
||||
// {
|
||||
// filterSeriesQuery = filter.SortOptions.SortField switch
|
||||
// {
|
||||
// SortField.SortName => filterSeriesQuery.OrderBy(s => s.series.SortName),
|
||||
// SortField.CreatedDate => filterSeriesQuery.OrderBy(s => s.bookmark.Created),
|
||||
// SortField.LastModifiedDate => filterSeriesQuery.OrderBy(s => s.bookmark.LastModified),
|
||||
// _ => filterSeriesQuery
|
||||
// };
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// filterSeriesQuery = filter.SortOptions.SortField switch
|
||||
// {
|
||||
// SortField.SortName => filterSeriesQuery.OrderByDescending(s => s.series.SortName),
|
||||
// SortField.CreatedDate => filterSeriesQuery.OrderByDescending(s => s.bookmark.Created),
|
||||
// SortField.LastModifiedDate => filterSeriesQuery.OrderByDescending(s => s.bookmark.LastModified),
|
||||
// _ => filterSeriesQuery
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
|
||||
query = filterSeriesQuery.Select(o => o.bookmark);
|
||||
}
|
||||
|
||||
|
@ -102,6 +102,10 @@ public class AppUserPreferences
|
||||
/// UI Site Global Setting: Should Kavita prompt user to confirm downloads that are greater than 100 MB.
|
||||
/// </summary>
|
||||
public bool PromptForDownloadSize { get; set; } = true;
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: Should Kavita disable CSS transitions
|
||||
/// </summary>
|
||||
public bool NoTransitions { get; set; } = false;
|
||||
|
||||
public AppUser AppUser { get; set; }
|
||||
public int AppUserId { get; set; }
|
||||
|
@ -33,9 +33,6 @@ public class Device : IEntityDate
|
||||
/// </summary>
|
||||
public DevicePlatform Platform { get; set; }
|
||||
|
||||
|
||||
//public ICollection<string> SupportedExtensions { get; set; } // TODO: This requires some sort of information at mangaFile level (unless i repack)
|
||||
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
|
||||
|
@ -12,7 +12,7 @@ public class Library : IEntityDate
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Update this summary with a way it's used, else let's remove it.
|
||||
/// This is not used, but planned once we build out a Library detail page
|
||||
/// </summary>
|
||||
[Obsolete("This has never been coded for. Likely we can remove it.")]
|
||||
public string CoverImage { get; set; }
|
||||
|
@ -1,232 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace API.Helpers.Filters;
|
||||
|
||||
// NOTE: I'm leaving this in, but I don't think it's needed. Will validate in next release.
|
||||
|
||||
//[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)]
|
||||
// public class ETagFromFilename : ActionFilterAttribute, IAsyncActionFilter
|
||||
// {
|
||||
// public override async Task OnActionExecutionAsync(ActionExecutingContext executingContext,
|
||||
// ActionExecutionDelegate next)
|
||||
// {
|
||||
// var request = executingContext.HttpContext.Request;
|
||||
//
|
||||
// var executedContext = await next();
|
||||
// var response = executedContext.HttpContext.Response;
|
||||
//
|
||||
// // Computing ETags for Response Caching on GET requests
|
||||
// if (request.Method == HttpMethod.Get.Method && response.StatusCode == (int) HttpStatusCode.OK)
|
||||
// {
|
||||
// ValidateETagForResponseCaching(executedContext);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private void ValidateETagForResponseCaching(ActionExecutedContext executedContext)
|
||||
// {
|
||||
// if (executedContext.Result == null)
|
||||
// {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// var request = executedContext.HttpContext.Request;
|
||||
// var response = executedContext.HttpContext.Response;
|
||||
//
|
||||
// var objectResult = executedContext.Result as ObjectResult;
|
||||
// if (objectResult == null) return;
|
||||
// var result = (PhysicalFileResult) objectResult.Value;
|
||||
//
|
||||
// // generate ETag from LastModified property
|
||||
// //var etag = GenerateEtagFromFilename(result.);
|
||||
//
|
||||
// // generates ETag from the entire response Content
|
||||
// //var etag = GenerateEtagFromResponseBodyWithHash(result);
|
||||
//
|
||||
// if (request.Headers.ContainsKey(HeaderNames.IfNoneMatch))
|
||||
// {
|
||||
// // fetch etag from the incoming request header
|
||||
// var incomingEtag = request.Headers[HeaderNames.IfNoneMatch].ToString();
|
||||
//
|
||||
// // if both the etags are equal
|
||||
// // raise a 304 Not Modified Response
|
||||
// if (incomingEtag.Equals(etag))
|
||||
// {
|
||||
// executedContext.Result = new StatusCodeResult((int) HttpStatusCode.NotModified);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // add ETag response header
|
||||
// response.Headers.Add(HeaderNames.ETag, new[] {etag});
|
||||
// }
|
||||
//
|
||||
// private static string GenerateEtagFromFilename(HttpResponse response, string filename, int maxAge = 10)
|
||||
// {
|
||||
// if (filename is not {Length: > 0}) return string.Empty;
|
||||
// var hashContent = filename + File.GetLastWriteTimeUtc(filename);
|
||||
// using var sha1 = SHA256.Create();
|
||||
// return string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")));
|
||||
// }
|
||||
// }
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
public class ETagFilterAttribute : Attribute, IActionFilter
|
||||
{
|
||||
private readonly int[] _statusCodes;
|
||||
|
||||
public ETagFilterAttribute(params int[] statusCodes)
|
||||
{
|
||||
_statusCodes = statusCodes;
|
||||
if (statusCodes.Length == 0) _statusCodes = new[] { 200 };
|
||||
}
|
||||
|
||||
public void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
/* Nothing needs to be done here */
|
||||
}
|
||||
|
||||
public void OnActionExecuted(ActionExecutedContext context)
|
||||
{
|
||||
if (context.HttpContext.Request.Method != "GET" || context.HttpContext.Request.Method != "HEAD") return;
|
||||
if (!_statusCodes.Contains(context.HttpContext.Response.StatusCode)) return;
|
||||
|
||||
var etag = string.Empty;
|
||||
//I just serialize the result to JSON, could do something less costly
|
||||
if (context.Result is PhysicalFileResult fileResult)
|
||||
{
|
||||
// Do a cheap LastWriteTime etag gen
|
||||
etag = ETagGenerator.GenerateEtagFromFilename(fileResult.FileName);
|
||||
context.HttpContext.Response.Headers.LastModified = File.GetLastWriteTimeUtc(fileResult.FileName).ToLongDateString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(etag))
|
||||
{
|
||||
var content = JsonConvert.SerializeObject(context.Result);
|
||||
etag = ETagGenerator.GetETag(context.HttpContext.Request.Path.ToString(), Encoding.UTF8.GetBytes(content));
|
||||
}
|
||||
|
||||
|
||||
if (context.HttpContext.Request.Headers.IfNoneMatch.ToString() == etag)
|
||||
{
|
||||
context.Result = new StatusCodeResult(304);
|
||||
}
|
||||
|
||||
//context.HttpContext.Response.Headers.ETag = etag;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// Helper class that generates the etag from a key (route) and content (response)
|
||||
public static class ETagGenerator
|
||||
{
|
||||
public static string GetETag(string key, byte[] contentBytes)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(key);
|
||||
var combinedBytes = Combine(keyBytes, contentBytes);
|
||||
|
||||
return GenerateETag(combinedBytes);
|
||||
}
|
||||
|
||||
private static string GenerateETag(byte[] data)
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
var hash = md5.ComputeHash(data);
|
||||
var hex = BitConverter.ToString(hash);
|
||||
return hex.Replace("-", "");
|
||||
}
|
||||
|
||||
private static byte[] Combine(byte[] a, byte[] b)
|
||||
{
|
||||
var c = new byte[a.Length + b.Length];
|
||||
Buffer.BlockCopy(a, 0, c, 0, a.Length);
|
||||
Buffer.BlockCopy(b, 0, c, a.Length, b.Length);
|
||||
return c;
|
||||
}
|
||||
|
||||
public static string GenerateEtagFromFilename(string filename)
|
||||
{
|
||||
if (filename is not {Length: > 0}) return string.Empty;
|
||||
var hashContent = filename + File.GetLastWriteTimeUtc(filename);
|
||||
using var md5 = MD5.Create();
|
||||
return string.Concat(md5.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")));
|
||||
}
|
||||
}
|
||||
|
||||
// /// <summary>
|
||||
// /// Enables HTTP Response CacheControl management with ETag values.
|
||||
// /// </summary>
|
||||
// public class ClientCacheWithEtagAttribute : ActionFilterAttribute
|
||||
// {
|
||||
// private readonly TimeSpan _clientCache;
|
||||
//
|
||||
// private readonly HttpMethod[] _supportedRequestMethods = {
|
||||
// HttpMethod.Get,
|
||||
// HttpMethod.Head
|
||||
// };
|
||||
//
|
||||
// /// <summary>
|
||||
// /// Default constructor
|
||||
// /// </summary>
|
||||
// /// <param name="clientCacheInSeconds">Indicates for how long the client should cache the response. The value is in seconds</param>
|
||||
// public ClientCacheWithEtagAttribute(int clientCacheInSeconds)
|
||||
// {
|
||||
// _clientCache = TimeSpan.FromSeconds(clientCacheInSeconds);
|
||||
// }
|
||||
//
|
||||
// public override async Task OnActionExecutionAsync(ActionExecutingContext executingContext, ActionExecutionDelegate next)
|
||||
// {
|
||||
//
|
||||
// if (executingContext.Response?.Content == null)
|
||||
// {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// var body = await executingContext.Response.Content.ReadAsStringAsync();
|
||||
// if (body == null)
|
||||
// {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// var computedEntityTag = GetETag(Encoding.UTF8.GetBytes(body));
|
||||
//
|
||||
// if (actionExecutedContext.Request.Headers.IfNoneMatch.Any()
|
||||
// && actionExecutedContext.Request.Headers.IfNoneMatch.First().Tag.Trim('"').Equals(computedEntityTag, StringComparison.InvariantCultureIgnoreCase))
|
||||
// {
|
||||
// actionExecutedContext.Response.StatusCode = HttpStatusCode.NotModified;
|
||||
// actionExecutedContext.Response.Content = null;
|
||||
// }
|
||||
//
|
||||
// var cacheControlHeader = new CacheControlHeaderValue
|
||||
// {
|
||||
// Private = true,
|
||||
// MaxAge = _clientCache
|
||||
// };
|
||||
//
|
||||
// actionExecutedContext.Response.Headers.ETag = new EntityTagHeaderValue($"\"{computedEntityTag}\"", false);
|
||||
// actionExecutedContext.Response.Headers.CacheControl = cacheControlHeader;
|
||||
// }
|
||||
//
|
||||
// private static string GetETag(byte[] contentBytes)
|
||||
// {
|
||||
// using (var md5 = MD5.Create())
|
||||
// {
|
||||
// var hash = md5.ComputeHash(contentBytes);
|
||||
// string hex = BitConverter.ToString(hash);
|
||||
// return hex.Replace("-", "");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Serilog.Formatting.Display;
|
||||
|
||||
namespace API.Logging;
|
||||
|
||||
@ -39,6 +40,7 @@ public static class LogLevelOptions
|
||||
|
||||
public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration)
|
||||
{
|
||||
const string outputTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}";
|
||||
return configuration
|
||||
.MinimumLevel
|
||||
.ControlledBy(LogLevelSwitch)
|
||||
@ -51,11 +53,11 @@ public static class LogLevelOptions
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithThreadId()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.Console(new MessageTemplateTextFormatter(outputTemplate))
|
||||
.WriteTo.File(LogFile,
|
||||
shared: true,
|
||||
rollingInterval: RollingInterval.Day,
|
||||
outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {CorrelationId} {ThreadId}] [{Level}] {SourceContext} {Message:lj}{NewLine}{Exception}");
|
||||
outputTemplate: outputTemplate);
|
||||
}
|
||||
|
||||
public static void SwitchLogLevel(string level)
|
||||
|
@ -962,7 +962,7 @@ public class BookService : IBookService
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception. Some css don't have style rules ending in ; */
|
||||
//Swallow exception. Some css don't have style rules ending in ';'
|
||||
}
|
||||
|
||||
body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1");
|
||||
|
@ -46,6 +46,7 @@ public class EmailService : IEmailService
|
||||
_unitOfWork = unitOfWork;
|
||||
_downloadService = downloadService;
|
||||
|
||||
|
||||
FlurlHttp.ConfigureClient(DefaultApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
}
|
||||
@ -126,15 +127,17 @@ public class EmailService : IEmailService
|
||||
return await SendEmailWithFiles(emailLink + "/api/sendto", data.FilePaths, data.DestinationEmail);
|
||||
}
|
||||
|
||||
private static async Task<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
|
||||
private async Task<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var response = await (url)
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("x-kavita-installId", settings.InstallId)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.GetStringAsync();
|
||||
@ -152,15 +155,17 @@ public class EmailService : IEmailService
|
||||
}
|
||||
|
||||
|
||||
private static async Task<bool> SendEmailWithPost(string url, object data, int timeoutSecs = 30)
|
||||
private async Task<bool> SendEmailWithPost(string url, object data, int timeoutSecs = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var response = await (url)
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("x-kavita-installId", settings.InstallId)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.PostJsonAsync(data);
|
||||
@ -182,10 +187,12 @@ public class EmailService : IEmailService
|
||||
{
|
||||
try
|
||||
{
|
||||
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
var response = await (url)
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("x-kavita-installId", settings.InstallId)
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.PostMultipartAsync(mp =>
|
||||
{
|
||||
|
@ -2,6 +2,7 @@
|
||||
using API.Data.Metadata;
|
||||
using API.Entities.Enums;
|
||||
using API.Parser;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
|
@ -150,7 +150,7 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
|
||||
private void OnError(object sender, ErrorEventArgs e)
|
||||
{
|
||||
_logger.LogError(e.GetException(), "[LibraryWatcher] An error occured, likely too many watches occured at once. Restarting Watchers");
|
||||
_logger.LogError(e.GetException(), "[LibraryWatcher] An error occured, likely too many changes occured at once or the folder being watched was deleted. Restarting Watchers");
|
||||
Task.Run(RestartWatching);
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using API.Entities.Enums;
|
||||
using API.Services;
|
||||
using API.Parser;
|
||||
|
||||
namespace API.Parser;
|
||||
namespace API.Services.Tasks.Scanner.Parser;
|
||||
|
||||
public interface IDefaultParser
|
||||
{
|
||||
@ -36,81 +36,81 @@ public class DefaultParser : IDefaultParser
|
||||
var fileName = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
ParserInfo ret;
|
||||
|
||||
if (Services.Tasks.Scanner.Parser.Parser.IsEpub(filePath))
|
||||
if (Parser.IsEpub(filePath))
|
||||
{
|
||||
ret = new ParserInfo()
|
||||
ret = new ParserInfo
|
||||
{
|
||||
Chapters = Services.Tasks.Scanner.Parser.Parser.ParseChapter(fileName) ?? Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(fileName),
|
||||
Series = Services.Tasks.Scanner.Parser.Parser.ParseSeries(fileName) ?? Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(fileName),
|
||||
Volumes = Services.Tasks.Scanner.Parser.Parser.ParseVolume(fileName) ?? Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(fileName),
|
||||
Chapters = Parser.ParseChapter(fileName) ?? Parser.ParseComicChapter(fileName),
|
||||
Series = Parser.ParseSeries(fileName) ?? Parser.ParseComicSeries(fileName),
|
||||
Volumes = Parser.ParseVolume(fileName) ?? Parser.ParseComicVolume(fileName),
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Services.Tasks.Scanner.Parser.Parser.ParseFormat(filePath),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
FullFilePath = filePath
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
ret = new ParserInfo()
|
||||
ret = new ParserInfo
|
||||
{
|
||||
Chapters = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(fileName) : Services.Tasks.Scanner.Parser.Parser.ParseChapter(fileName),
|
||||
Series = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(fileName) : Services.Tasks.Scanner.Parser.Parser.ParseSeries(fileName),
|
||||
Volumes = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(fileName) : Services.Tasks.Scanner.Parser.Parser.ParseVolume(fileName),
|
||||
Chapters = type == LibraryType.Comic ? Parser.ParseComicChapter(fileName) : Parser.ParseChapter(fileName),
|
||||
Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName),
|
||||
Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName),
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Services.Tasks.Scanner.Parser.Parser.ParseFormat(filePath),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
Title = Path.GetFileNameWithoutExtension(fileName),
|
||||
FullFilePath = filePath
|
||||
};
|
||||
}
|
||||
|
||||
if (Services.Tasks.Scanner.Parser.Parser.IsImage(filePath) && Services.Tasks.Scanner.Parser.Parser.IsCoverImage(filePath)) return null;
|
||||
if (Parser.IsCoverImage(filePath)) return null;
|
||||
|
||||
if (Services.Tasks.Scanner.Parser.Parser.IsImage(filePath))
|
||||
if (Parser.IsImage(filePath))
|
||||
{
|
||||
// Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders.
|
||||
ret.Volumes = Services.Tasks.Scanner.Parser.Parser.DefaultVolume;
|
||||
ret.Chapters = Services.Tasks.Scanner.Parser.Parser.DefaultChapter;
|
||||
ret.Volumes = Parser.DefaultVolume;
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Series = string.Empty;
|
||||
}
|
||||
|
||||
if (ret.Series == string.Empty || Services.Tasks.Scanner.Parser.Parser.IsImage(filePath))
|
||||
if (ret.Series == string.Empty || Parser.IsImage(filePath))
|
||||
{
|
||||
// Try to parse information out of each folder all the way to rootPath
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
var edition = Services.Tasks.Scanner.Parser.Parser.ParseEdition(fileName);
|
||||
var edition = Parser.ParseEdition(fileName);
|
||||
if (!string.IsNullOrEmpty(edition))
|
||||
{
|
||||
ret.Series = Services.Tasks.Scanner.Parser.Parser.CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic);
|
||||
ret.Edition = edition;
|
||||
}
|
||||
|
||||
var isSpecial = type == LibraryType.Comic ? Services.Tasks.Scanner.Parser.Parser.IsComicSpecial(fileName) : Services.Tasks.Scanner.Parser.Parser.IsMangaSpecial(fileName);
|
||||
var isSpecial = type == LibraryType.Comic ? Parser.IsComicSpecial(fileName) : Parser.IsMangaSpecial(fileName);
|
||||
// We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that
|
||||
// could cause a problem as Omake is a special term, but there is valid volume/chapter information.
|
||||
if (ret.Chapters == Services.Tasks.Scanner.Parser.Parser.DefaultChapter && ret.Volumes == Services.Tasks.Scanner.Parser.Parser.DefaultVolume && isSpecial)
|
||||
if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.DefaultVolume && isSpecial)
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret); // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder
|
||||
}
|
||||
|
||||
// If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name
|
||||
if (Services.Tasks.Scanner.Parser.Parser.HasSpecialMarker(fileName))
|
||||
if (Parser.HasSpecialMarker(fileName))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.Chapters = Services.Tasks.Scanner.Parser.Parser.DefaultChapter;
|
||||
ret.Volumes = Services.Tasks.Scanner.Parser.Parser.DefaultVolume;
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Volumes = Parser.DefaultVolume;
|
||||
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(ret.Series))
|
||||
{
|
||||
ret.Series = Services.Tasks.Scanner.Parser.Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
}
|
||||
|
||||
// Pdfs may have .pdf in the series name, remove that
|
||||
if (Services.Tasks.Scanner.Parser.Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
{
|
||||
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
|
||||
}
|
||||
@ -127,35 +127,55 @@ public class DefaultParser : IDefaultParser
|
||||
/// <param name="ret">Expects a non-null ParserInfo which this method will populate</param>
|
||||
public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret)
|
||||
{
|
||||
var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath).ToList();
|
||||
var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath)
|
||||
.Where(f => !Parser.IsMangaSpecial(f))
|
||||
.ToList();
|
||||
|
||||
if (fallbackFolders.Count == 0)
|
||||
{
|
||||
var rootFolderName = _directoryService.FileSystem.DirectoryInfo.FromDirectoryName(rootPath).Name;
|
||||
var series = Parser.ParseSeries(rootFolderName);
|
||||
|
||||
if (string.IsNullOrEmpty(series))
|
||||
{
|
||||
ret.Series = Parser.CleanTitle(rootFolderName, type is LibraryType.Comic);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(series) && (string.IsNullOrEmpty(ret.Series) || !rootFolderName.Contains(ret.Series)))
|
||||
{
|
||||
ret.Series = series;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < fallbackFolders.Count; i++)
|
||||
{
|
||||
var folder = fallbackFolders[i];
|
||||
if (Services.Tasks.Scanner.Parser.Parser.IsMangaSpecial(folder)) continue;
|
||||
|
||||
var parsedVolume = type is LibraryType.Manga ? Services.Tasks.Scanner.Parser.Parser.ParseVolume(folder) : Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(folder);
|
||||
var parsedChapter = type is LibraryType.Manga ? Services.Tasks.Scanner.Parser.Parser.ParseChapter(folder) : Services.Tasks.Scanner.Parser.Parser.ParseComicChapter(folder);
|
||||
var parsedVolume = type is LibraryType.Manga ? Parser.ParseVolume(folder) : Parser.ParseComicVolume(folder);
|
||||
var parsedChapter = type is LibraryType.Manga ? Parser.ParseChapter(folder) : Parser.ParseComicChapter(folder);
|
||||
|
||||
if (!parsedVolume.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume) || !parsedChapter.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter))
|
||||
if (!parsedVolume.Equals(Parser.DefaultVolume) || !parsedChapter.Equals(Parser.DefaultChapter))
|
||||
{
|
||||
if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume)) && !parsedVolume.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume))
|
||||
{
|
||||
ret.Volumes = parsedVolume;
|
||||
}
|
||||
if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter)) && !parsedChapter.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter))
|
||||
{
|
||||
ret.Chapters = parsedChapter;
|
||||
}
|
||||
if ((string.IsNullOrEmpty(ret.Volumes) || ret.Volumes.Equals(Parser.DefaultVolume)) && !parsedVolume.Equals(Parser.DefaultVolume))
|
||||
{
|
||||
ret.Volumes = parsedVolume;
|
||||
}
|
||||
if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter)) && !parsedChapter.Equals(Parser.DefaultChapter))
|
||||
{
|
||||
ret.Chapters = parsedChapter;
|
||||
}
|
||||
}
|
||||
|
||||
// Generally users group in series folders. Let's try to parse series from the top folder
|
||||
if (!folder.Equals(ret.Series) && i == fallbackFolders.Count - 1)
|
||||
{
|
||||
var series = Services.Tasks.Scanner.Parser.Parser.ParseSeries(folder);
|
||||
var series = Parser.ParseSeries(folder);
|
||||
|
||||
if (string.IsNullOrEmpty(series))
|
||||
{
|
||||
ret.Series = Services.Tasks.Scanner.Parser.Parser.CleanTitle(folder, type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(folder, type is LibraryType.Comic);
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -633,13 +633,11 @@ public class ProcessSeries : IProcessSeries
|
||||
|
||||
void AddGenre(Genre genre)
|
||||
{
|
||||
//chapter.Genres.Add(genre);
|
||||
GenreHelper.AddGenreIfNotExists(chapter.Genres, genre);
|
||||
}
|
||||
|
||||
void AddTag(Tag tag, bool added)
|
||||
{
|
||||
//chapter.Tags.Add(tag);
|
||||
TagHelper.AddTagIfNotExists(chapter.Tags, tag);
|
||||
}
|
||||
|
||||
|
@ -206,8 +206,6 @@ public class ScannerService : IScannerService
|
||||
var scanElapsedTime = await ScanFiles(library, new []{folderPath}, false, TrackFiles, true);
|
||||
_logger.LogInformation("ScanFiles for {Series} took {Time}", series.Name, scanElapsedTime);
|
||||
|
||||
//await Task.WhenAll(processTasks);
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name));
|
||||
|
||||
// Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder
|
||||
|
@ -35,6 +35,7 @@ export interface Preferences {
|
||||
globalPageLayoutMode: PageLayoutMode;
|
||||
blurUnreadSummaries: boolean;
|
||||
promptForDownloadSize: boolean;
|
||||
noTransitions: boolean;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
|
||||
|
@ -4,6 +4,7 @@ import { Chapter } from '../_models/chapter';
|
||||
import { CollectionTag } from '../_models/collection-tag';
|
||||
import { Device } from '../_models/device/device';
|
||||
import { Library } from '../_models/library';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { ReadingList } from '../_models/reading-list';
|
||||
import { Series } from '../_models/series';
|
||||
import { Volume } from '../_models/volume';
|
||||
@ -92,6 +93,10 @@ export interface ActionItem<T> {
|
||||
callback: (action: ActionItem<T>, data: T) => void;
|
||||
requiresAdmin: boolean;
|
||||
children: Array<ActionItem<T>>;
|
||||
/**
|
||||
* An optional class which applies to an item. ie) danger on a delete action
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Indicates that there exists a separate list will be loaded from an API.
|
||||
* Rule: If using this, only one child should exist in children with the Action for dynamicList.
|
||||
@ -168,7 +173,15 @@ export class ActionFactoryService {
|
||||
|
||||
dummyCallback(action: ActionItem<any>, data: any) {}
|
||||
|
||||
_resetActions() {
|
||||
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
|
||||
if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
|
||||
// Remove Send To as it doesn't apply
|
||||
return actions.filter(item => item.title !== 'Send To');
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
private _resetActions() {
|
||||
this.libraryActions = [
|
||||
{
|
||||
action: Action.Scan,
|
||||
@ -226,6 +239,13 @@ export class ActionFactoryService {
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Scan,
|
||||
title: 'Scan Series',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'Add to',
|
||||
@ -263,18 +283,22 @@ export class ActionFactoryService {
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Scan,
|
||||
title: 'Scan Series',
|
||||
action: Action.Submenu,
|
||||
title: 'Send To',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Edit',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
children: []
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
@ -301,10 +325,25 @@ export class ActionFactoryService {
|
||||
title: 'Delete',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Edit',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: true,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.volumeActions = [
|
||||
@ -345,15 +384,15 @@ export class ActionFactoryService {
|
||||
]
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Details',
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
action: Action.Edit,
|
||||
title: 'Details',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
@ -397,29 +436,11 @@ export class ActionFactoryService {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Details',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
// RBS will handle rendering this, so non-admins with download are appicable
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'Send To',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
// dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
// return {'title': d.name, 'data': d};
|
||||
// }), shareReplay())),
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
@ -433,6 +454,21 @@ export class ActionFactoryService {
|
||||
}
|
||||
],
|
||||
},
|
||||
// RBS will handle rendering this, so non-admins with download are appicable
|
||||
{
|
||||
action: Action.Download,
|
||||
title: 'Download',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Edit,
|
||||
title: 'Details',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
this.readingListActions = [
|
||||
@ -448,6 +484,7 @@ export class ActionFactoryService {
|
||||
title: 'Delete',
|
||||
callback: this.dummyCallback,
|
||||
requiresAdmin: false,
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -471,6 +508,7 @@ export class ActionFactoryService {
|
||||
action: Action.Delete,
|
||||
title: 'Clear',
|
||||
callback: this.dummyCallback,
|
||||
class: 'danger',
|
||||
requiresAdmin: false,
|
||||
children: [],
|
||||
},
|
||||
@ -494,4 +532,5 @@ export class ActionFactoryService {
|
||||
actions.forEach((action) => this.applyCallback(action, callback));
|
||||
return actions;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -3,11 +3,12 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Inject, Injectable, OnDestroy, Renderer2, RendererFactory2, SecurityContext } from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { map, ReplaySubject, Subject, takeUntil, take } from 'rxjs';
|
||||
import { map, ReplaySubject, Subject, takeUntil, take, distinctUntilChanged, Observable } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ConfirmService } from '../shared/confirm.service';
|
||||
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
|
||||
import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme';
|
||||
import { AccountService } from './account.service';
|
||||
import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
|
||||
|
||||
@ -24,7 +25,7 @@ export class ThemeService implements OnDestroy {
|
||||
|
||||
private themesSource = new ReplaySubject<SiteTheme[]>(1);
|
||||
public themes$ = this.themesSource.asObservable();
|
||||
|
||||
|
||||
/**
|
||||
* Maintain a cache of themes. SignalR will inform us if we need to refresh cache
|
||||
*/
|
||||
|
@ -11,7 +11,7 @@
|
||||
<div class="col-md-6 col-sm-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div *ngIf="userForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
@ -23,7 +23,7 @@
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.touched">
|
||||
<div *ngIf="userForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
|
@ -14,7 +14,7 @@
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
|
||||
<div *ngIf="email?.errors?.required">
|
||||
This field is required
|
||||
|
@ -32,7 +32,9 @@
|
||||
<label for="backup-tasks" class="form-label">Days of Backups</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="backupTasksTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #backupTasksTooltip>The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</ng-template>
|
||||
<span class="visually-hidden" id="backup-tasks-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
|
||||
<input id="backup-tasks" aria-describedby="backup-tasks-help" class="form-control" formControlName="totalBackups" type="number" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
|
||||
<input id="backup-tasks" aria-describedby="backup-tasks-help" class="form-control" formControlName="totalBackups"
|
||||
type="number" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('totalBackups')?.invalid && settingsForm.get('totalBackups')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('totalBackups')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
You must have at least 1 backup
|
||||
@ -50,7 +52,9 @@
|
||||
<label for="log-tasks" class="form-label">Days of Logs</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="logTasksTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #logTasksTooltip>The number of logs to maintain. Default is 30, minumum is 1, maximum is 30.</ng-template>
|
||||
<span class="visually-hidden" id="log-tasks-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
|
||||
<input id="log-tasks" aria-describedby="log-tasks-help" class="form-control" formControlName="totalLogs" type="number" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57">
|
||||
<input id="log-tasks" aria-describedby="log-tasks-help" class="form-control" formControlName="totalLogs"
|
||||
type="number" step="1" min="1" max="30" onkeypress="return event.charCode >= 48 && event.charCode <= 57"
|
||||
[class.is-invalid]="settingsForm.get('totalLogs')?.invalid && settingsForm.get('totalLogs')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('totalLogs')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.min">
|
||||
You must have at least 1 log
|
||||
@ -68,7 +72,8 @@
|
||||
<label for="logging-level-port" class="form-label">Logging Level</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="loggingLevelTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #loggingLevelTooltip>Use debug to help identify issues. Debug can eat up a lot of disk space.</ng-template>
|
||||
<span class="visually-hidden" id="logging-level-port-help">Port the server listens on.</span>
|
||||
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel">
|
||||
<select id="logging-level-port" aria-describedby="logging-level-port-help" class="form-select" formControlName="loggingLevel"
|
||||
[class.is-invalid]="settingsForm.get('loggingLevel')?.invalid && settingsForm.get('loggingLevel')?.touched">
|
||||
<option *ngFor="let level of logLevels" [value]="level">{{level | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
@ -1,16 +1,17 @@
|
||||
<app-nav-header></app-nav-header>
|
||||
<div [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
|
||||
<a id="content"></a>
|
||||
<app-side-nav *ngIf="navService.sideNavVisibility$ | async as sideNavVisibile"></app-side-nav>
|
||||
<div class="container-fluid" [ngClass]="{'g-0': !(navService.sideNavVisibility$ | async)}">
|
||||
<div style="padding: 20px 0 0;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
|
||||
<div class="companion-bar" [ngClass]="{'companion-bar-content': !(navService.sideNavCollapsed$ | async)}">
|
||||
<router-outlet></router-outlet>
|
||||
<div [ngClass]="{'no-transitions' : (transitionState$ | async)}">
|
||||
<app-nav-header></app-nav-header>
|
||||
<div [ngClass]="{'closed' : (navService.sideNavCollapsed$ | async), 'content-wrapper': navService.sideNavVisibility$ | async}">
|
||||
<a id="content"></a>
|
||||
<app-side-nav *ngIf="navService.sideNavVisibility$ | async as sideNavVisibile"></app-side-nav>
|
||||
<div class="container-fluid" [ngClass]="{'g-0': !(navService.sideNavVisibility$ | async)}">
|
||||
<div style="padding: 20px 0 0;" *ngIf="navService.sideNavVisibility$ | async else noSideNav">
|
||||
<div class="companion-bar" [ngClass]="{'companion-bar-content': !(navService.sideNavCollapsed$ | async)}">
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #noSideNav>
|
||||
<router-outlet></router-outlet>
|
||||
</ng-template>
|
||||
</div>
|
||||
<ng-template #noSideNav>
|
||||
<router-outlet></router-outlet>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Component, HostListener, Inject, OnInit } from '@angular/core';
|
||||
import { NavigationStart, Router } from '@angular/router';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { distinctUntilChanged, map, take } from 'rxjs/operators';
|
||||
import { AccountService } from './_services/account.service';
|
||||
import { LibraryService } from './_services/library.service';
|
||||
import { MessageHubService } from './_services/message-hub.service';
|
||||
@ -9,6 +9,7 @@ import { filter } from 'rxjs/operators';
|
||||
import { NgbModal, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { DeviceService } from './_services/device.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@ -17,6 +18,8 @@ import { DeviceService } from './_services/device.service';
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
transitionState$!: Observable<boolean>;
|
||||
|
||||
constructor(private accountService: AccountService, public navService: NavService,
|
||||
private messageHub: MessageHubService, private libraryService: LibraryService,
|
||||
router: Router, private ngbModal: NgbModal, ratingConfig: NgbRatingConfig,
|
||||
@ -35,6 +38,10 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
|
||||
this.transitionState$ = this.accountService.currentUser$.pipe(map((user) => {
|
||||
if (!user) return false;
|
||||
return user.preferences.noTransitions;
|
||||
}));
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
|
@ -15,6 +15,7 @@ import { NavModule } from './nav/nav.module';
|
||||
import { DevicesComponent } from './devices/devices.component';
|
||||
|
||||
|
||||
|
||||
// Disable Web Animations if the user's browser (such as iOS 12.5.5) does not support this.
|
||||
const disableAnimations = !('animate' in document.documentElement);
|
||||
if (disableAnimations) console.error("Web Animations have been disabled as your current browser does not support this.");
|
||||
|
@ -16,9 +16,13 @@
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<div class="input-group {{series.nameLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'nameLocked' }"></ng-container>
|
||||
<input id="name" class="form-control" formControlName="name" type="text">
|
||||
<div class="input-group">
|
||||
<input id="name" class="form-control" formControlName="name" type="text" [class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -26,9 +30,15 @@
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">Sort Name</label>
|
||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}">
|
||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -418,7 +428,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">Close</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="save()">Save</button>
|
||||
<button type="submit" class="btn btn-primary" [disabled]="!editSeriesForm.valid" (click)="save()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
|
||||
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { forkJoin, Observable, of, Subject } from 'rxjs';
|
||||
import { map, takeUntil } from 'rxjs/operators';
|
||||
@ -126,9 +126,9 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
this.editSeriesForm = this.fb.group({
|
||||
id: new FormControl(this.series.id, []),
|
||||
summary: new FormControl('', []),
|
||||
name: new FormControl(this.series.name, []),
|
||||
name: new FormControl(this.series.name, [Validators.required]),
|
||||
localizedName: new FormControl(this.series.localizedName, []),
|
||||
sortName: new FormControl(this.series.sortName, []),
|
||||
sortName: new FormControl(this.series.sortName, [Validators.required]),
|
||||
rating: new FormControl(this.series.userRating, []),
|
||||
|
||||
coverImageIndex: new FormControl(0, []),
|
||||
@ -209,6 +209,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
||||
this.seriesVolumes = volumes;
|
||||
this.isLoadingVolumes = false;
|
||||
|
||||
if (this.seriesVolumes.length === 1) {
|
||||
this.imageUrls.push(...this.seriesVolumes[0].chapters.map((c: Chapter) => this.imageService.getChapterCoverImage(c.id)));
|
||||
} else {
|
||||
this.imageUrls.push(...this.seriesVolumes.map(v => this.imageService.getVolumeCoverImage(v.id)));
|
||||
}
|
||||
|
||||
volumes.forEach(v => {
|
||||
this.volumeCollapsed[v.name] = true;
|
||||
});
|
||||
|
@ -134,6 +134,12 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
|
||||
.filter(item => item.action !== Action.Edit);
|
||||
this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
|
||||
if (this.isChapter) {
|
||||
const chapter = this.utilityService.asChapter(this.data);
|
||||
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);
|
||||
} else {
|
||||
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, this.chapters[0]);
|
||||
}
|
||||
|
||||
this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
|
||||
this.libraryType = type;
|
||||
|
@ -10,7 +10,7 @@
|
||||
<!-- Non Submenu items -->
|
||||
<ng-container *ngIf="action.children === undefined || action?.children?.length === 0 || action.dynamicList != undefined; else submenuDropdown">
|
||||
|
||||
<ng-container *ngIf="action.dynamicList != undefined && toDList(action.dynamicList | async) as dList; else justItem">
|
||||
<ng-container *ngIf="action.dynamicList != undefined && (action.dynamicList | async | dynamicList) as dList; else justItem">
|
||||
<ng-container *ngFor="let dynamicItem of dList">
|
||||
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||
</ng-container>
|
||||
@ -23,7 +23,7 @@
|
||||
<ng-template #submenuDropdown>
|
||||
<!-- Submenu items -->
|
||||
<ng-container *ngIf="shouldRenderSubMenu(action, action.children[0].dynamicList | async)">
|
||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right" (mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventEvent($event)">
|
||||
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left" (click)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventEvent($event)">
|
||||
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{action.title}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
||||
|
@ -19,6 +19,7 @@ export class CardActionablesComponent implements OnInit {
|
||||
@Input() disabled: boolean = false;
|
||||
@Output() actionHandler = new EventEmitter<ActionItem<any>>();
|
||||
|
||||
|
||||
isAdmin: boolean = false;
|
||||
canDownload: boolean = false;
|
||||
submenu: {[key: string]: NgbDropdown} = {};
|
||||
@ -74,10 +75,4 @@ export class CardActionablesComponent implements OnInit {
|
||||
action._extra = dynamicItem;
|
||||
this.performAction(event, action);
|
||||
}
|
||||
|
||||
toDList(d: any) {
|
||||
console.log('d: ', d);
|
||||
if (d === undefined || d === null) return [];
|
||||
return d as {title: string, data: any}[];
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import { Series } from 'src/app/_models/series';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
|
||||
@ -126,9 +126,11 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
public utilityService: UtilityService, private downloadService: DownloadService,
|
||||
public bulkSelectionService: BulkSelectionService,
|
||||
private messageHub: MessageHubService, private accountService: AccountService,
|
||||
private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef) {}
|
||||
private scrollService: ScrollService, private readonly cdRef: ChangeDetectorRef,
|
||||
private actionFactoryService: ActionFactoryService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
if (this.entity.hasOwnProperty('promoted') && this.entity.hasOwnProperty('title')) {
|
||||
this.suppressArchiveWarning = true;
|
||||
this.cdRef.markForCheck();
|
||||
@ -172,6 +174,8 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
} else if (this.utilityService.isSeries(this.entity)) {
|
||||
this.tooltipTitle = this.title || (this.utilityService.asSeries(this.entity).name);
|
||||
}
|
||||
|
||||
this.filterSendTo();
|
||||
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
@ -192,26 +196,10 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
chapter.pagesRead = updateEvent.pagesRead;
|
||||
}
|
||||
} else {
|
||||
// Ignore
|
||||
return;
|
||||
// re-request progress for the series
|
||||
// const s = this.utilityService.asSeries(this.entity);
|
||||
// let pagesRead = 0;
|
||||
// if (s.hasOwnProperty('volumes')) {
|
||||
// s.volumes.forEach(v => {
|
||||
// v.chapters.forEach(c => {
|
||||
// if (c.id === updateEvent.chapterId) {
|
||||
// c.pagesRead = updateEvent.pagesRead;
|
||||
// }
|
||||
// pagesRead += c.pagesRead;
|
||||
// });
|
||||
// });
|
||||
// s.pagesRead = pagesRead;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.read = updateEvent.pagesRead;
|
||||
this.cdRef.detectChanges();
|
||||
});
|
||||
@ -312,4 +300,20 @@ export class CardItemComponent implements OnInit, OnDestroy {
|
||||
this.selection.emit(this.selected);
|
||||
this.cdRef.detectChanges();
|
||||
}
|
||||
|
||||
filterSendTo() {
|
||||
if (!this.actions || this.actions.length === 0) return;
|
||||
|
||||
if (this.utilityService.isChapter(this.entity)) {
|
||||
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.entity as Chapter);
|
||||
} else if (this.utilityService.isVolume(this.entity)) {
|
||||
const vol = this.utilityService.asVolume(this.entity);
|
||||
this.actions = this.actionFactoryService.filterSendToAction(this.actions, vol.chapters[0]);
|
||||
} else if (this.utilityService.isSeries(this.entity)) {
|
||||
const series = (this.entity as Series);
|
||||
if (series.format === MangaFormat.EPUB || series.format === MangaFormat.PDF) {
|
||||
this.actions = this.actions.filter(a => a.title !== 'Send To');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ import { ListItemComponent } from './list-item/list-item.component';
|
||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||
import { SeriesInfoCardsComponent } from './series-info-cards/series-info-cards.component';
|
||||
import { DownloadIndicatorComponent } from './download-indicator/download-indicator.component';
|
||||
import { DynamicListPipe } from './dynamic-list.pipe';
|
||||
|
||||
|
||||
|
||||
@ -48,6 +49,7 @@ import { DownloadIndicatorComponent } from './download-indicator/download-indica
|
||||
ListItemComponent,
|
||||
SeriesInfoCardsComponent,
|
||||
DownloadIndicatorComponent,
|
||||
DynamicListPipe,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -71,6 +71,11 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
this.form = this.fb.group({
|
||||
coverImageUrl: new FormControl('', [])
|
||||
});
|
||||
|
||||
this.imageUrls.forEach(url => {
|
||||
|
||||
});
|
||||
console.log('imageUrls: ', this.imageUrls);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
@ -79,6 +84,11 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a base64 encoding for an Image. Used in manual file upload flow.
|
||||
* @param img
|
||||
* @returns
|
||||
*/
|
||||
getBase64Image(img: HTMLImageElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
@ -95,6 +105,25 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
|
||||
selectImage(index: number) {
|
||||
if (this.selectedIndex === index) { return; }
|
||||
|
||||
// If we load custom images of series/chapters/covers, then those urls are not properly encoded, so on select we have to clean them up
|
||||
if (!this.imageUrls[index].startsWith('data:image/')) {
|
||||
const imgUrl = this.imageUrls[index];
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = imgUrl;
|
||||
img.onload = (e) => this.handleUrlImageAdd(img, index);
|
||||
img.onerror = (e) => {
|
||||
this.toastr.error('The image could not be fetched due to server refusing request. Please download and upload from file instead.');
|
||||
this.form.get('coverImageUrl')?.setValue('');
|
||||
this.cdRef.markForCheck();
|
||||
};
|
||||
this.form.get('coverImageUrl')?.setValue('');
|
||||
this.cdRef.markForCheck();
|
||||
this.selectedBase64Url.emit(this.imageUrls[this.selectedIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedIndex = index;
|
||||
this.cdRef.markForCheck();
|
||||
this.imageSelected.emit(this.selectedIndex);
|
||||
@ -115,9 +144,9 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
const url = this.form.get('coverImageUrl')?.value.trim();
|
||||
if (!url && url === '') return;
|
||||
loadImage(url?: string) {
|
||||
url = url || this.form.get('coverImageUrl')?.value.trim();
|
||||
if (!url || url === '') return;
|
||||
|
||||
this.uploadService.uploadByUrl(url).subscribe(filename => {
|
||||
const img = new Image();
|
||||
@ -134,6 +163,8 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
changeMode(mode: 'url') {
|
||||
this.mode = mode;
|
||||
this.setupEnterHandler();
|
||||
@ -161,7 +192,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
handleFileImageAdd(e: any) {
|
||||
if (e.target == null) return;
|
||||
|
||||
this.imageUrls.push(e.target.result);
|
||||
this.imageUrls.push(e.target.result); // This is base64 already
|
||||
this.imageUrlsChange.emit(this.imageUrls);
|
||||
this.selectedIndex += 1;
|
||||
this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image
|
||||
@ -169,9 +200,14 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
handleUrlImageAdd(img: HTMLImageElement) {
|
||||
handleUrlImageAdd(img: HTMLImageElement, index: number = -1) {
|
||||
const url = this.getBase64Image(img);
|
||||
this.imageUrls.push(url);
|
||||
if (index >= 0) {
|
||||
this.imageUrls[index] = url;
|
||||
} else {
|
||||
this.imageUrls.push(url);
|
||||
}
|
||||
|
||||
this.imageUrlsChange.emit(this.imageUrls);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
|
14
UI/Web/src/app/cards/dynamic-list.pipe.ts
Normal file
14
UI/Web/src/app/cards/dynamic-list.pipe.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
@Pipe({
|
||||
name: 'dynamicList',
|
||||
pure: true
|
||||
})
|
||||
export class DynamicListPipe implements PipeTransform {
|
||||
|
||||
transform(value: any): Array<{title: string, data: any}> {
|
||||
if (value === undefined || value === null) return [];
|
||||
return value as {title: string, data: any}[];
|
||||
}
|
||||
|
||||
}
|
@ -14,11 +14,11 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-left: auto; padding-right: 3%;">
|
||||
<button class="btn btn-icon btn-sm" title="Shortcuts" (click)="openShortcutModal()">
|
||||
<button class="btn btn-icon" title="Shortcuts" (click)="openShortcutModal()">
|
||||
<i class="fa-regular fa-rectangle-list" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Keyboard Shortcuts Modal</span>
|
||||
</button>
|
||||
<button *ngIf="!bookmarkMode && hasBookmarkRights" class="btn btn-icon btn-sm" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked"
|
||||
<button *ngIf="!bookmarkMode && hasBookmarkRights" class="btn btn-icon" role="checkbox" [attr.aria-checked]="CurrentPageBookmarked"
|
||||
title="{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}" (click)="bookmarkPage()">
|
||||
<i class="{{CurrentPageBookmarked ? 'fa' : 'far'}} fa-bookmark" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{CurrentPageBookmarked ? 'Unbookmark Page' : 'Bookmark Page'}}</span>
|
||||
@ -98,8 +98,8 @@
|
||||
<div class="mb-3" *ngIf="pageOptions != undefined && pageOptions.ceil != undefined">
|
||||
<span class="visually-hidden" id="slider-info"></span>
|
||||
<div class="row g-0">
|
||||
<button class="btn btn-sm btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-sm btn-icon col-1" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-icon col-1" [disabled]="prevChapterDisabled" (click)="loadPrevChapter();resetMenuCloseTimer();" title="Prev Chapter/Volume"><i class="fa fa-fast-backward" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-icon col-2" [disabled]="prevPageDisabled || pageNum === 0" (click)="goToPage(0);resetMenuCloseTimer();" title="First Page"><i class="fa fa-step-backward" aria-hidden="true"></i></button>
|
||||
<div class="col custom-slider" *ngIf="pageOptions.ceil > 0; else noSlider">
|
||||
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" [manualRefresh]="refreshSlider" (userChangeEnd)="sliderPageUpdate($event);startMenuCloseTimer()" (userChange)="sliderDragUpdate($event)" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
||||
</div>
|
||||
@ -108,8 +108,8 @@
|
||||
<ngx-slider [options]="pageOptions" [value]="pageNum" aria-describedby="slider-info" (userChangeEnd)="startMenuCloseTimer()" (userChangeStart)="cancelMenuCloseTimer();"></ngx-slider>
|
||||
</div>
|
||||
</ng-template>
|
||||
<button class="btn btn-sm btn-icon col-2" [disabled]="nextPageDisabled || pageNum >= maxPages - 1" (click)="goToPage(this.maxPages);resetMenuCloseTimer();" title="Last Page"><i class="fa fa-step-forward" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-sm btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-icon col-2" [disabled]="nextPageDisabled || pageNum >= maxPages - 1" (click)="goToPage(this.maxPages);resetMenuCloseTimer();" title="Last Page"><i class="fa fa-step-forward" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-icon col-1" [disabled]="nextChapterDisabled" (click)="loadNextChapter();resetMenuCloseTimer();" title="Next Chapter/Volume"><i class="fa fa-fast-forward" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row pt-4 ms-2 me-2">
|
||||
|
@ -1526,6 +1526,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (this.bookmarkMode) return;
|
||||
|
||||
const pageNum = this.pageNum;
|
||||
const isDouble = this.layoutMode === LayoutMode.Double || this.layoutMode === LayoutMode.DoubleReversed;
|
||||
|
||||
|
@ -331,7 +331,7 @@
|
||||
<label for="release-year-min" class="form-label">Release Year</label>
|
||||
<input type="text" id="release-year-min" formControlName="min" class="form-control" style="width: 62px" placeholder="Min">
|
||||
</div>
|
||||
<div style="margin-top: 37px !important">
|
||||
<div style="margin-top: 37px !important; width: 49px;">
|
||||
<i class="fa-solid fa-minus" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="mb-3" style="margin-top: 0.5rem">
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnDestroy, OnInit, ViewChild } from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { fromEvent, Subject } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, filter, takeUntil, takeWhile, tap } from 'rxjs/operators';
|
||||
import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs/operators';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { MangaFile } from 'src/app/_models/manga-file';
|
||||
import { ScrollService } from 'src/app/_services/scroll.service';
|
||||
@ -68,6 +68,13 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
|
||||
fromEvent(elem.nativeElement, 'scroll').pipe(debounceTime(20)).subscribe(() => this.checkBackToTopNeeded(elem.nativeElement));
|
||||
}
|
||||
})).subscribe();
|
||||
|
||||
// Sometimes the top event emitter can be slow, so let's also check when a navigation occurs and recalculate
|
||||
this.router.events
|
||||
.pipe(filter(event => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
this.checkBackToTopNeeded(this.scrollElem);
|
||||
});
|
||||
}
|
||||
|
||||
checkBackToTopNeeded(elem: HTMLElement) {
|
||||
|
@ -34,7 +34,7 @@ import { DefaultDatePipe } from './default-date.pipe';
|
||||
MangaFormatIconPipe,
|
||||
LibraryTypePipe,
|
||||
SafeStylePipe,
|
||||
DefaultDatePipe
|
||||
DefaultDatePipe,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@ -54,7 +54,7 @@ import { DefaultDatePipe } from './default-date.pipe';
|
||||
MangaFormatIconPipe,
|
||||
LibraryTypePipe,
|
||||
SafeStylePipe,
|
||||
DefaultDatePipe
|
||||
DefaultDatePipe,
|
||||
]
|
||||
})
|
||||
export class PipeModule { }
|
||||
|
@ -14,7 +14,7 @@
|
||||
<form [formGroup]="registerForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" [class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
@ -37,11 +37,18 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
|
||||
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password"
|
||||
aria-describedby="password-help" [class.is-invalid]="registerForm.get('password')?.invalid && registerForm.get('password')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('password')?.errors?.required">
|
||||
This field is required
|
||||
</div>
|
||||
<div *ngIf="registerForm.get('password')?.errors?.minlength">
|
||||
This field must be at least {{registerForm.get('password')?.errors?.minlength.requiredLength}} characters
|
||||
</div>
|
||||
<div *ngIf="registerForm.get('password')?.errors?.maxlength">
|
||||
This field must be no more than {{registerForm.get('password')?.errors?.maxlength.requiredLength}} characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -29,7 +29,7 @@ export class AddEmailToAccountMigrationModalComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
this.registerForm.addControl('username', new FormControl(this.username, [Validators.required]));
|
||||
this.registerForm.addControl('email', new FormControl('', [Validators.required, Validators.email]));
|
||||
this.registerForm.addControl('password', new FormControl(this.password, [Validators.required]));
|
||||
this.registerForm.addControl('password', new FormControl(this.password, [Validators.required, Validators.minLength(6), Validators.maxLength(32)]));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,8 @@
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<input id="username" class="form-control" formControlName="username" type="text"
|
||||
[class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
@ -22,7 +23,8 @@
|
||||
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required readonly>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required readonly
|
||||
[class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
|
@ -5,7 +5,8 @@
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" class="form-control" formControlName="username" type="text">
|
||||
<input id="username" class="form-control" formControlName="username" type="text"
|
||||
[class.is-invalid]="registerForm.get('username')?.invalid && registerForm.get('username')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('username')?.errors?.required">
|
||||
This field is required
|
||||
@ -19,7 +20,8 @@
|
||||
<span class="visually-hidden" id="email-help">
|
||||
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
|
||||
</span>
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required aria-describedby="email-help">
|
||||
<input class="form-control" type="email" id="email" formControlName="email" required aria-describedby="email-help"
|
||||
[class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
@ -36,7 +38,8 @@
|
||||
Password must be between 6 and 32 characters in length
|
||||
</ng-template>
|
||||
<span class="visually-hidden" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
|
||||
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
|
||||
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password"
|
||||
type="password" aria-describedby="password-help" [class.is-invalid]="registerForm.get('password')?.invalid && registerForm.get('password')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('password')?.errors?.required">
|
||||
This field is required
|
||||
|
@ -5,7 +5,7 @@
|
||||
<form [formGroup]="registerForm" (ngSubmit)="submit()">
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input class="form-control custom-input" type="email" id="email" formControlName="email" required>
|
||||
<input class="form-control custom-input" type="email" id="email" formControlName="email" [class.is-invalid]="registerForm.get('email')?.invalid && registerForm.get('email')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
|
||||
<div *ngIf="registerForm.get('email')?.errors?.required">
|
||||
This field is required
|
||||
|
@ -58,11 +58,11 @@
|
||||
|
||||
<div [ngStyle]="{'height': ScrollingBlockHeight}" class="main-container container-fluid pt-2" *ngIf="series !== undefined" #scrollingBlock>
|
||||
<div class="row mb-3 info-container">
|
||||
<div class="image-container col-xl-1 col-lg-2 col-md-2 col-xs-4 col-sm-6 d-none d-sm-block">
|
||||
<div class="image-container col-4 col-sm-6 col-md-4 col-lg-4 col-xl-2 col-xxl-2 d-none d-sm-block">
|
||||
<app-image height="100%" maxHeight="400px" objectFit="contain" background="none" [imageUrl]="seriesImage"></app-image>
|
||||
<!-- NOTE: We can put continue point here as Vol X Ch Y or just Ch Y or Book Z ?-->
|
||||
</div>
|
||||
<div class="col-md-10 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="col-xlg-10 col-lg-8 col-md-8 col-xs-8 col-sm-6 mt-2">
|
||||
<div class="row g-0">
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" (click)="read()">
|
||||
|
@ -497,6 +497,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
||||
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
|
||||
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
|
||||
|
||||
|
||||
this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => {
|
||||
this.relations = [
|
||||
...relations.prequels.map(item => this.createRelatedSeries(item, RelationKind.Prequel)),
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-3 col-sm-12 pe-2">
|
||||
<label for="settings-name" class="form-label">Device Name</label>
|
||||
<input id="settings-name" class="form-control" formControlName="name" type="text">
|
||||
<input id="settings-name" class="form-control" formControlName="name" type="text" [class.is-invalid]="settingsForm.get('name')?.invalid && settingsForm.get('name')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('name')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
@ -15,7 +15,10 @@
|
||||
<label for="email" class="form-label">Email</label> <i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
|
||||
<ng-template #emailTooltip>This email will be used to accept the file via Send To</ng-template>
|
||||
<span class="visually-hidden" id="email-help">The number of backups to maintain. Default is 30, minumum is 1, maximum is 30.</span>
|
||||
<input id="email" aria-describedby="email-help" class="form-control" formControlName="email" type="email" placeholder="@kindle.com">
|
||||
|
||||
<input id="email" aria-describedby="email-help"
|
||||
class="form-control" formControlName="email" type="email"
|
||||
placeholder="@kindle.com" [class.is-invalid]="settingsForm.get('email')?.invalid && settingsForm.get('email')?.touched">
|
||||
<ng-container *ngIf="settingsForm.get('email')?.errors as errors">
|
||||
<p class="invalid-feedback" *ngIf="errors.email">
|
||||
This must be a valid email
|
||||
@ -28,7 +31,8 @@
|
||||
|
||||
<div class="col-md-3 col-sm-12 pe-2">
|
||||
<label for="device-platform" class="form-label">Device Platform</label>
|
||||
<select id="device-platform" aria-describedby="device-platform-help" class="form-select" formControlName="platform">
|
||||
<select id="device-platform" aria-describedby="device-platform-help" class="form-select" formControlName="platform"
|
||||
[class.is-invalid]="settingsForm.get('platform')?.invalid && settingsForm.get('platform')?.touched">
|
||||
<option *ngFor="let patform of devicePlatforms" [value]="patform">{{patform | devicePlatform}}</option>
|
||||
</select>
|
||||
<ng-container *ngIf="settingsForm.get('platform')?.errors as errors">
|
||||
|
@ -60,6 +60,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-6 col-sm-12 pe-2 mb-2">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="no-transitions" role="switch" formControlName="noTransitions" class="form-check-input" aria-describedby="settings-global-noTransitions-help" [value]="true" aria-labelledby="auto-close-label">
|
||||
<label class="form-check-label" for="no-transitions">Disable Animations</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="noTransitionsTooltip" role="button" tabindex="0"></i>
|
||||
</div>
|
||||
|
||||
<ng-template #noTransitionsTooltip>Turns off animations in the site. Useful for e-ink readers</ng-template>
|
||||
<span class="visually-hidden" id="settings-global-noTransitions-help">Turns off animations in the site. Useful for e-ink readers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
|
||||
<button type="button" class="flex-fill btn btn-secondary me-2" (click)="resetForm()" aria-describedby="reading-panel">Reset</button>
|
||||
<button type="submit" class="flex-fill btn btn-primary" (click)="save()" aria-describedby="reading-panel" [disabled]="!settingsForm.dirty">Save</button>
|
||||
@ -287,7 +299,8 @@
|
||||
<form [formGroup]="passwordChangeForm">
|
||||
<div class="mb-3">
|
||||
<label for="oldpass" class="form-label">Current Password</label>
|
||||
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword">
|
||||
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
|
||||
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
|
||||
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
||||
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
|
||||
This field is required
|
||||
@ -297,7 +310,8 @@
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new-password">New Password</label>
|
||||
<input class="form-control" type="password" id="new-password" formControlName="password">
|
||||
<input class="form-control" type="password" id="new-password" formControlName="password"
|
||||
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
|
||||
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
||||
<div *ngIf="password?.errors?.required">
|
||||
This field is required
|
||||
@ -306,7 +320,8 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations">
|
||||
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations"
|
||||
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
|
||||
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
|
||||
<div *ngIf="!passwordsMatch">
|
||||
Passwords must match
|
||||
|
@ -134,6 +134,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, []));
|
||||
this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, []));
|
||||
this.settingsForm.addControl('promptForDownloadSize', new FormControl(this.user.preferences.promptForDownloadSize, []));
|
||||
this.settingsForm.addControl('noTransitions', new FormControl(this.user.preferences.noTransitions, []));
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
@ -188,6 +189,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode);
|
||||
this.settingsForm.get('blurUnreadSummaries')?.setValue(this.user.preferences.blurUnreadSummaries);
|
||||
this.settingsForm.get('promptForDownloadSize')?.setValue(this.user.preferences.promptForDownloadSize);
|
||||
this.settingsForm.get('noTransitions')?.setValue(this.user.preferences.noTransitions);
|
||||
this.cdRef.markForCheck();
|
||||
this.settingsForm.markAsPristine();
|
||||
}
|
||||
@ -225,6 +227,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10),
|
||||
blurUnreadSummaries: modelSettings.blurUnreadSummaries,
|
||||
promptForDownloadSize: modelSettings.promptForDownloadSize,
|
||||
noTransitions: modelSettings.noTransitions,
|
||||
};
|
||||
|
||||
this.observableHandles.push(this.accountService.updatePreferences(data).subscribe((updatedPrefs) => {
|
||||
|
@ -10,6 +10,10 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-transitions, .no-transitions * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
|
||||
hr {
|
||||
background-color: var(--hr-color);
|
||||
|
Loading…
x
Reference in New Issue
Block a user