mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-03-06 00:55:46 -05:00
Custom keybinds, Default language per Library, and bugfixes (#4162)
Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
parent
f9280f6861
commit
2c6eddfebb
@ -17,109 +17,19 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace API.Tests.Repository;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public class SeriesRepositoryTests
|
||||
public class SeriesRepositoryTests(ITestOutputHelper testOutputHelper): AbstractDbTest(testOutputHelper)
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
private readonly DbConnection? _connection;
|
||||
private readonly DataContext _context;
|
||||
|
||||
private const string CacheDirectory = "C:/kavita/config/cache/";
|
||||
private const string CoverImageDirectory = "C:/kavita/config/covers/";
|
||||
private const string BackupDirectory = "C:/kavita/config/backups/";
|
||||
private const string DataDirectory = "C:/data/";
|
||||
|
||||
public SeriesRepositoryTests()
|
||||
{
|
||||
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
|
||||
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
|
||||
|
||||
_context = new DataContext(contextOptions);
|
||||
Task.Run(SeedDb).GetAwaiter().GetResult();
|
||||
|
||||
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
|
||||
var mapper = config.CreateMapper();
|
||||
_unitOfWork = new UnitOfWork(_context, mapper, null!);
|
||||
}
|
||||
|
||||
#region Setup
|
||||
|
||||
private static DbConnection CreateInMemoryDatabase()
|
||||
{
|
||||
var connection = new SqliteConnection("Filename=:memory:");
|
||||
|
||||
connection.Open();
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
private async Task<bool> SeedDb()
|
||||
{
|
||||
await _context.Database.MigrateAsync();
|
||||
var filesystem = CreateFileSystem();
|
||||
|
||||
await Seed.SeedSettings(_context,
|
||||
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
|
||||
|
||||
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
|
||||
setting.Value = CacheDirectory;
|
||||
|
||||
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
|
||||
setting.Value = BackupDirectory;
|
||||
|
||||
_context.ServerSetting.Update(setting);
|
||||
|
||||
var lib = new LibraryBuilder("Manga")
|
||||
.WithFolderPath(new FolderPathBuilder("C:/data/").Build())
|
||||
.Build();
|
||||
|
||||
_context.AppUser.Add(new AppUser()
|
||||
{
|
||||
UserName = "majora2007",
|
||||
Libraries = new List<Library>()
|
||||
{
|
||||
lib
|
||||
}
|
||||
});
|
||||
|
||||
return await _context.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
private async Task ResetDb()
|
||||
{
|
||||
_context.Series.RemoveRange(_context.Series.ToList());
|
||||
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
|
||||
_context.Genre.RemoveRange(_context.Genre.ToList());
|
||||
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
|
||||
_context.Person.RemoveRange(_context.Person.ToList());
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static MockFileSystem CreateFileSystem()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
|
||||
fileSystem.AddDirectory("C:/kavita/config/");
|
||||
fileSystem.AddDirectory(CacheDirectory);
|
||||
fileSystem.AddDirectory(CoverImageDirectory);
|
||||
fileSystem.AddDirectory(BackupDirectory);
|
||||
fileSystem.AddDirectory(DataDirectory);
|
||||
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task SetupSeriesData()
|
||||
private async Task SetupSeriesData(IUnitOfWork unitOfWork)
|
||||
{
|
||||
var library = new LibraryBuilder("GetFullSeriesByAnyName Manga", LibraryType.Manga)
|
||||
.WithFolderPath(new FolderPathBuilder("C:/data/manga/").Build())
|
||||
.WithFolderPath(new FolderPathBuilder(DataDirectory+"manga/").Build())
|
||||
.WithSeries(new SeriesBuilder("The Idaten Deities Know Only Peace")
|
||||
.WithLocalizedName("Heion Sedai no Idaten-tachi")
|
||||
.WithFormat(MangaFormat.Archive)
|
||||
@ -130,8 +40,8 @@ public class SeriesRepositoryTests
|
||||
.Build())
|
||||
.Build();
|
||||
|
||||
_unitOfWork.LibraryRepository.Add(library);
|
||||
await _unitOfWork.CommitAsync();
|
||||
unitOfWork.LibraryRepository.Add(library);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
|
||||
@ -142,11 +52,11 @@ public class SeriesRepositoryTests
|
||||
[InlineData("Hitomi-chan wa Hitomishiri", MangaFormat.Archive, "", "Hitomi-chan is Shy With Strangers")]
|
||||
public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected)
|
||||
{
|
||||
await ResetDb();
|
||||
await SetupSeriesData();
|
||||
var (unitOfWork, _, _) = await CreateDatabase();
|
||||
await SetupSeriesData(unitOfWork);
|
||||
|
||||
var series =
|
||||
await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName,
|
||||
await unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName,
|
||||
2, format, false);
|
||||
if (expected == null)
|
||||
{
|
||||
@ -165,8 +75,8 @@ public class SeriesRepositoryTests
|
||||
[InlineData(0, "", null)] // Case 3: Return null if neither exist
|
||||
public async Task GetPlusSeriesDto_Should_PrioritizeAniListId_Correctly(int externalAniListId, string? webLinks, int? expectedAniListId)
|
||||
{
|
||||
// Arrange
|
||||
await ResetDb();
|
||||
var (unitOfWork, _, _) = await CreateDatabase();
|
||||
await SetupSeriesData(unitOfWork);
|
||||
|
||||
var series = new SeriesBuilder("Test Series")
|
||||
.WithFormat(MangaFormat.Archive)
|
||||
@ -195,12 +105,12 @@ public class SeriesRepositoryTests
|
||||
ReleaseYear = 2021
|
||||
};
|
||||
|
||||
_unitOfWork.LibraryRepository.Add(library);
|
||||
_unitOfWork.SeriesRepository.Add(series);
|
||||
await _unitOfWork.CommitAsync();
|
||||
unitOfWork.LibraryRepository.Add(library);
|
||||
unitOfWork.SeriesRepository.Add(series);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
// Act
|
||||
var result = await _unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id);
|
||||
var result = await unitOfWork.SeriesRepository.GetPlusSeriesDto(series.Id);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
|
||||
@ -182,18 +182,15 @@ public class LibraryController : BaseApiController
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("has-files-at-root")]
|
||||
public ActionResult<IDictionary<string, bool>> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto)
|
||||
public ActionResult<IList<string>> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto)
|
||||
{
|
||||
var results = new Dictionary<string, bool>();
|
||||
foreach (var root in dto.Roots)
|
||||
{
|
||||
results.TryAdd(root,
|
||||
_directoryService
|
||||
.GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly)
|
||||
.Any());
|
||||
}
|
||||
var foldersWithFilesAtRoot = dto.Roots
|
||||
.Where(root => _directoryService
|
||||
.GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly)
|
||||
.Any())
|
||||
.ToList();
|
||||
|
||||
return Ok(results);
|
||||
return Ok(foldersWithFilesAtRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -658,6 +655,7 @@ public class LibraryController : BaseApiController
|
||||
library.EnableMetadata = dto.EnableMetadata;
|
||||
library.RemovePrefixForSortName = dto.RemovePrefixForSortName;
|
||||
library.InheritWebLinksFromFirstChapter = dto.InheritWebLinksFromFirstChapter;
|
||||
library.DefaultLanguage = dto.DefaultLanguage;
|
||||
|
||||
library.LibraryFileTypes = dto.FileGroupTypes
|
||||
.Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id})
|
||||
|
||||
@ -14,6 +14,7 @@ using API.SignalR;
|
||||
using Flurl.Http;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
@ -62,9 +63,15 @@ public class UploadController : BaseApiController
|
||||
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
|
||||
{
|
||||
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace('/', '_').Replace(':', '_');
|
||||
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty);
|
||||
try
|
||||
{
|
||||
var format = await dto.Url.GetFileFormatAsync();
|
||||
if (string.IsNullOrEmpty(format))
|
||||
{
|
||||
// Fallback to unreliable parsing if needed
|
||||
format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty);
|
||||
}
|
||||
|
||||
var path = await dto.Url
|
||||
.DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
|
||||
|
||||
@ -499,7 +506,7 @@ public class UploadController : BaseApiController
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id);
|
||||
if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist"));
|
||||
|
||||
await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, true);
|
||||
await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, chooseBetterImage: false);
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
@ -121,6 +121,7 @@ public class UsersController : BaseApiController
|
||||
existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
|
||||
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
|
||||
existingPreferences.DataSaver = preferencesDto.DataSaver;
|
||||
existingPreferences.CustomKeyBinds = preferencesDto.CustomKeyBinds;
|
||||
|
||||
var allLibs = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id))
|
||||
.Select(l => l.Id).ToList();
|
||||
|
||||
@ -2,8 +2,12 @@
|
||||
|
||||
public sealed record UpdateStreamPositionDto
|
||||
{
|
||||
public string StreamName { get; set; }
|
||||
public int Id { get; set; }
|
||||
public int FromPosition { get; set; }
|
||||
public int ToPosition { get; set; }
|
||||
public int Id { get; set; }
|
||||
public string StreamName { get; set; }
|
||||
/// <summary>
|
||||
/// If the <see cref="ToPosition"/> has taken into account non-visible items
|
||||
/// </summary>
|
||||
public bool PositionIncludesInvisible { get; set; }
|
||||
}
|
||||
|
||||
@ -77,4 +77,6 @@ public sealed record LibraryDto
|
||||
public bool RemovePrefixForSortName { get; set; } = false;
|
||||
/// <inheritdoc cref="Library.InheritWebLinksFromFirstChapter"/>
|
||||
public bool InheritWebLinksFromFirstChapter { get; init; }
|
||||
/// <inheritdoc cref="Library.DefaultLanguage"/>
|
||||
public string DefaultLanguage { get; init; }
|
||||
}
|
||||
|
||||
@ -48,6 +48,8 @@ public sealed record UpdateLibraryDto
|
||||
/// <inheritdoc cref="Library.InheritWebLinksFromFirstChapter"/>
|
||||
[Required]
|
||||
public bool InheritWebLinksFromFirstChapter { get; init; }
|
||||
/// <inheritdoc cref="Library.DefaultLanguage"/>
|
||||
public string DefaultLanguage { get; init; }
|
||||
/// <summary>
|
||||
/// What types of files to allow the scanner to pickup
|
||||
/// </summary>
|
||||
|
||||
@ -40,6 +40,9 @@ public sealed record UserPreferencesDto
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.DataSaver"/>
|
||||
[Required]
|
||||
public bool DataSaver { get; set; } = false;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.CustomKeyBinds"/>
|
||||
[Required]
|
||||
public Dictionary<KeyBindTarget, IList<KeyBind>> CustomKeyBinds { get; set; } = [];
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.AniListScrobblingEnabled"/>
|
||||
public bool AniListScrobblingEnabled { get; set; }
|
||||
|
||||
@ -155,6 +155,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<Library>()
|
||||
.Property(b => b.EnableMetadata)
|
||||
.HasDefaultValue(true);
|
||||
builder.Entity<Library>()
|
||||
.Property(l => l.DefaultLanguage)
|
||||
.HasDefaultValue(string.Empty);
|
||||
|
||||
builder.Entity<Chapter>()
|
||||
.Property(b => b.WebLinks)
|
||||
@ -293,6 +296,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue(new List<HighlightSlot>());
|
||||
|
||||
builder.Entity<AppUserPreferences>()
|
||||
.Property(p => p.CustomKeyBinds)
|
||||
.HasJsonConversion([])
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue(new Dictionary<KeyBindTarget, IList<KeyBind>>());
|
||||
|
||||
builder.Entity<AppUser>()
|
||||
.Property(user => user.IdentityProvider)
|
||||
.HasDefaultValue(IdentityProvider.Kavita);
|
||||
|
||||
3941
API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.Designer.cs
generated
Normal file
3941
API/Data/Migrations/20251026234845_LibraryDefaultLanguageCustomKeyBinds.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class LibraryDefaultLanguageCustomKeyBinds : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DefaultLanguage",
|
||||
table: "Library",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CustomKeyBinds",
|
||||
table: "AppUserPreferences",
|
||||
type: "TEXT",
|
||||
nullable: true,
|
||||
defaultValue: "{}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultLanguage",
|
||||
table: "Library");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomKeyBinds",
|
||||
table: "AppUserPreferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -571,6 +571,11 @@ namespace API.Data.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<string>("CustomKeyBinds")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("{}");
|
||||
|
||||
b.Property<bool>("DataSaver")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -1466,6 +1471,11 @@ namespace API.Data.Migrations
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DefaultLanguage")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<bool>("EnableMetadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@ -16,7 +16,7 @@ public interface IMediaErrorRepository
|
||||
void Attach(MediaError error);
|
||||
void Remove(MediaError error);
|
||||
void Remove(IList<MediaError> errors);
|
||||
Task<MediaError> Find(string filename);
|
||||
Task<MediaError?> Find(string filename);
|
||||
IEnumerable<MediaErrorDto> GetAllErrorDtosAsync();
|
||||
Task<bool> ExistsAsync(MediaError error);
|
||||
Task DeleteAll();
|
||||
|
||||
@ -49,6 +49,7 @@ public interface IPersonRepository
|
||||
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null, PersonIncludes includes = PersonIncludes.None);
|
||||
|
||||
Task<string?> GetCoverImageAsync(int personId);
|
||||
Task<IList<string?>> GetAllCoverImagesAsync();
|
||||
Task<string?> GetCoverImageByNameAsync(string name);
|
||||
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
|
||||
Task<PagedList<BrowsePersonDto>> GetBrowsePersonDtos(int userId, BrowsePersonFilterDto filter, UserParams userParams);
|
||||
@ -167,6 +168,13 @@ public class PersonRepository : IPersonRepository
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<string?>> GetAllCoverImagesAsync()
|
||||
{
|
||||
return await _context.Person
|
||||
.Select(p => p.CoverImage)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<string?> GetCoverImageByNameAsync(string name)
|
||||
{
|
||||
var normalized = name.ToNormalized();
|
||||
@ -358,7 +366,8 @@ public class PersonRepository : IPersonRepository
|
||||
.Select(cp => cp.Chapter)
|
||||
.RestrictAgainstAgeRestriction(ageRating)
|
||||
.RestrictByLibrary(userLibs)
|
||||
.OrderBy(ch => ch.SortOrder)
|
||||
.OrderBy(ch => ch.Volume.MinNumber) // Group/Sort volumes as well
|
||||
.ThenBy(ch => ch.SortOrder)
|
||||
.Take(20)
|
||||
.ProjectTo<StandaloneChapterDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Data;
|
||||
using API.Entities.Enums;
|
||||
@ -175,9 +176,15 @@ public class AppUserPreferences
|
||||
/// <summary>
|
||||
/// Enable data saver mode across Kavita, limiting information that is pre-fetched
|
||||
/// </summary>
|
||||
/// <remarks>Currenty only integrated into the PDF reader</remarks>
|
||||
/// <remarks>Currently only integrated into the PDF reader</remarks>
|
||||
public bool DataSaver { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// JSON dictionary mappings for custom keybinds across the web app.
|
||||
/// Values are a list of key codes that need to be pressed at the same time for the keybind to be valid
|
||||
/// </summary>
|
||||
public Dictionary<KeyBindTarget, IList<KeyBind>> CustomKeyBinds { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region KavitaPlus
|
||||
@ -246,3 +253,13 @@ public class AppUserSocialPreferences
|
||||
/// </summary>
|
||||
public bool SocialIncludeUnknowns { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed record KeyBind
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public bool Control { get; set; }
|
||||
public bool Shift { get; set; }
|
||||
public bool Meta { get; set; }
|
||||
public bool Alt { get; set; }
|
||||
public IList<string>? ControllerSequence { get; set; }
|
||||
}
|
||||
|
||||
39
API/Entities/Enums/UserPreferences/KeyBindTarget.cs
Normal file
39
API/Entities/Enums/UserPreferences/KeyBindTarget.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums.UserPreferences;
|
||||
|
||||
public enum KeyBindTarget
|
||||
{
|
||||
[Description(nameof(NavigateToSettings))]
|
||||
NavigateToSettings = 0,
|
||||
|
||||
[Description(nameof(OpenSearch))]
|
||||
OpenSearch = 1,
|
||||
|
||||
[Description(nameof(NavigateToScrobbling))]
|
||||
NavigateToScrobbling = 2,
|
||||
|
||||
[Description(nameof(ToggleFullScreen))]
|
||||
ToggleFullScreen = 3,
|
||||
|
||||
[Description(nameof(BookmarkPage))]
|
||||
BookmarkPage = 4,
|
||||
|
||||
[Description(nameof(OpenHelp))]
|
||||
OpenHelp = 5,
|
||||
|
||||
[Description(nameof(GoTo))]
|
||||
GoTo = 6,
|
||||
|
||||
[Description(nameof(ToggleMenu))]
|
||||
ToggleMenu = 7,
|
||||
|
||||
[Description(nameof(PageLeft))]
|
||||
PageLeft = 8,
|
||||
|
||||
[Description(nameof(PageRight))]
|
||||
PageRight = 9,
|
||||
|
||||
[Description(nameof(Escape))]
|
||||
Escape = 10,
|
||||
}
|
||||
@ -60,7 +60,10 @@ public class Library : IEntityDate, IHasCoverImage
|
||||
/// Should series inherit web links from the first chapter/volume
|
||||
/// </summary>
|
||||
public bool InheritWebLinksFromFirstChapter { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Language to assign to series if none is set in the metadata
|
||||
/// </summary>
|
||||
public string DefaultLanguage { get; set; } = "";
|
||||
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
|
||||
@ -1,13 +1,48 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
|
||||
namespace API.Extensions;
|
||||
#nullable enable
|
||||
|
||||
public static class FlurlExtensions
|
||||
{
|
||||
|
||||
private static readonly FileExtensionContentTypeProvider FileTypeProvider = new ();
|
||||
|
||||
/// <summary>
|
||||
/// Makes a head request to the url, and parses the first content type header to determine the content type
|
||||
/// </summary>
|
||||
/// <param name="url"></param>
|
||||
/// <returns></returns>
|
||||
public static async Task<string?> GetFileFormatAsync(this string url)
|
||||
{
|
||||
var headResponse = await url.AllowHttpStatus("2xx").HeadAsync();
|
||||
|
||||
// TODO: Move to new Headers class after merge with progress branch
|
||||
var contentTypeHeader = headResponse.Headers.FirstOrDefault("Content-Type");
|
||||
if (string.IsNullOrEmpty(contentTypeHeader))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var contentType = contentTypeHeader.Split(";").FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(contentType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// The mappings have legacy mappings like .jpe => image/jpeg. We reverse to get the newer stuff first
|
||||
return FileTypeProvider.Mappings
|
||||
.Reverse()
|
||||
.FirstOrDefault(m => m.Value.Equals(contentType, StringComparison.OrdinalIgnoreCase))
|
||||
.Key?.TrimStart('.');
|
||||
}
|
||||
|
||||
public static IFlurlRequest WithKavitaPlusHeaders(this string request, string license, string? anilistToken = null)
|
||||
{
|
||||
return request
|
||||
|
||||
@ -74,6 +74,7 @@ public class ImageService : IImageService
|
||||
public const string SeriesCoverImageRegex = @"series\d+";
|
||||
public const string CollectionTagCoverImageRegex = @"tag\d+";
|
||||
public const string ReadingListCoverImageRegex = @"readinglist\d+";
|
||||
public const string PersonCoverImageRegex = @"person\d+";
|
||||
|
||||
private const double WhiteThreshold = 0.95; // Colors with lightness above this are considered too close to white
|
||||
private const double BlackThreshold = 0.25; // Colors with lightness below this are considered too close to black
|
||||
|
||||
@ -618,7 +618,8 @@ public class SettingsService : ISettingsService
|
||||
|
||||
if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority)
|
||||
{
|
||||
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
|
||||
// Only check validity if we're changing into a value that would be used
|
||||
if (!string.IsNullOrEmpty(updateSettingsDto.OidcConfig.Authority) && !await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
|
||||
{
|
||||
throw new KavitaException("oidc-invalid-authority");
|
||||
}
|
||||
|
||||
@ -277,7 +277,18 @@ public class StreamService : IStreamService
|
||||
if (stream.Order == dto.ToPosition) return;
|
||||
|
||||
var list = user!.SideNavStreams.OrderBy(s => s.Order).ToList();
|
||||
OrderableHelper.ReorderItems(list, stream.Id, dto.ToPosition);
|
||||
|
||||
var wantedPosition = dto.ToPosition;
|
||||
if (!dto.PositionIncludesInvisible)
|
||||
{
|
||||
var visibleItems = list.Where(i => i.Visible).ToList();
|
||||
if (dto.ToPosition < 0 || dto.ToPosition >= visibleItems.Count) return;
|
||||
|
||||
var itemAtWantedPosition = visibleItems[dto.ToPosition];
|
||||
wantedPosition = list.IndexOf(itemAtWantedPosition);
|
||||
}
|
||||
|
||||
OrderableHelper.ReorderItems(list, stream.Id, wantedPosition);
|
||||
user.SideNavStreams = list;
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
|
||||
@ -89,7 +89,7 @@ public class BackupService : IBackupService
|
||||
await SendProgress(0F, "Started backup");
|
||||
await SendProgress(0.1F, "Copying core files");
|
||||
|
||||
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
|
||||
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow:s}Z".Replace("/", "_").Replace(":", "_");
|
||||
var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}_v{BuildInfo.Version}.zip");
|
||||
|
||||
if (File.Exists(zipPath))
|
||||
|
||||
@ -21,7 +21,7 @@ public interface ICleanupService
|
||||
{
|
||||
Task Cleanup();
|
||||
Task CleanupDbEntries();
|
||||
void CleanupCacheAndTempDirectories();
|
||||
Task CleanupCacheAndTempDirectories();
|
||||
void CleanupCacheDirectory();
|
||||
Task DeleteSeriesCoverImages();
|
||||
Task DeleteChapterCoverImages();
|
||||
@ -80,38 +80,34 @@ public class CleanupService : ICleanupService
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting Cleanup");
|
||||
|
||||
var cleanupSteps = new List<(Func<Task>, string)>
|
||||
{
|
||||
(() => Task.Run(() => _directoryService.ClearDirectory(_directoryService.TempDirectory)), "Cleaning temp directory"),
|
||||
(CleanupCacheAndTempDirectories, "Cleaning cache and temp directories"),
|
||||
(CleanupBackups, "Cleaning old database backups"),
|
||||
(ConsolidateProgress, "Consolidating Progress Events"),
|
||||
(CleanupMediaErrors, "Consolidating Media Errors"),
|
||||
(CleanupDbEntries, "Cleaning abandoned database rows"), // Cleanup DB before removing files linked to DB entries
|
||||
(DeleteSeriesCoverImages, "Cleaning deleted series cover images"),
|
||||
(DeleteChapterCoverImages, "Cleaning deleted chapter cover images"),
|
||||
(() => Task.WhenAll(DeleteTagCoverImages(), DeleteReadingListCoverImages(), DeletePersonCoverImages()), "Cleaning deleted cover images"),
|
||||
(CleanupLogs, "Cleaning old logs"),
|
||||
(EnsureChapterProgressIsCapped, "Cleaning progress events that exceed 100%")
|
||||
};
|
||||
|
||||
await SendProgress(0F, "Starting cleanup");
|
||||
|
||||
_logger.LogInformation("Cleaning temp directory");
|
||||
_directoryService.ClearDirectory(_directoryService.TempDirectory);
|
||||
for (var i = 0; i < cleanupSteps.Count; i++)
|
||||
{
|
||||
var (method, subtitle) = cleanupSteps[i];
|
||||
var progress = (float)(i + 1) / (cleanupSteps.Count + 1);
|
||||
|
||||
await SendProgress(0.1F, "Cleaning temp directory");
|
||||
CleanupCacheAndTempDirectories();
|
||||
_logger.LogInformation("{Message}", subtitle);
|
||||
await method();
|
||||
await SendProgress(progress, subtitle);
|
||||
}
|
||||
|
||||
await SendProgress(0.25F, "Cleaning old database backups");
|
||||
_logger.LogInformation("Cleaning old database backups");
|
||||
await CleanupBackups();
|
||||
|
||||
await SendProgress(0.35F, "Consolidating Progress Events");
|
||||
await ConsolidateProgress();
|
||||
|
||||
await SendProgress(0.4F, "Consolidating Media Errors");
|
||||
await CleanupMediaErrors();
|
||||
|
||||
await SendProgress(0.50F, "Cleaning deleted cover images");
|
||||
_logger.LogInformation("Cleaning deleted cover images");
|
||||
await DeleteSeriesCoverImages();
|
||||
await SendProgress(0.6F, "Cleaning deleted cover images");
|
||||
await DeleteChapterCoverImages();
|
||||
await SendProgress(0.7F, "Cleaning deleted cover images");
|
||||
await DeleteTagCoverImages();
|
||||
await DeleteReadingListCoverImages();
|
||||
await SendProgress(0.8F, "Cleaning old logs");
|
||||
await CleanupLogs();
|
||||
await SendProgress(0.9F, "Cleaning progress events that exceed 100%");
|
||||
await EnsureChapterProgressIsCapped();
|
||||
await SendProgress(0.95F, "Cleaning abandoned database rows");
|
||||
await CleanupDbEntries();
|
||||
await SendProgress(1F, "Cleanup finished");
|
||||
_logger.LogInformation("Cleanup finished");
|
||||
}
|
||||
@ -174,10 +170,20 @@ public class CleanupService : ICleanupService
|
||||
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove all person cover images no longer associated with a person in the database
|
||||
/// </summary>
|
||||
public async Task DeletePersonCoverImages()
|
||||
{
|
||||
var images = await _unitOfWork.PersonRepository.GetAllCoverImagesAsync();
|
||||
var files = _directoryService.GetFiles(_directoryService.CoverImageDirectory, ImageService.PersonCoverImageRegex);
|
||||
_directoryService.DeleteFiles(files.Where(file => !images.Contains(_directoryService.FileSystem.Path.GetFileName(file))));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all files and directories in the cache and temp directory
|
||||
/// </summary>
|
||||
public void CleanupCacheAndTempDirectories()
|
||||
public Task CleanupCacheAndTempDirectories()
|
||||
{
|
||||
_logger.LogInformation("Performing cleanup of Cache & Temp directories");
|
||||
_directoryService.ExistOrCreate(_directoryService.CacheDirectory);
|
||||
@ -194,6 +200,8 @@ public class CleanupService : ICleanupService
|
||||
}
|
||||
|
||||
_logger.LogInformation("Cache and temp directory purged");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void CleanupCacheDirectory()
|
||||
|
||||
@ -30,7 +30,7 @@ public interface ICoverDbService
|
||||
Task<string> DownloadPublisherImageAsync(string publisherName, EncodeFormat encodeFormat);
|
||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat);
|
||||
Task<string?> DownloadPersonImageAsync(Person person, EncodeFormat encodeFormat, string url);
|
||||
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false);
|
||||
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true);
|
||||
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false);
|
||||
Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false);
|
||||
}
|
||||
@ -472,7 +472,8 @@ public class CoverDbService : ICoverDbService
|
||||
/// <param name="url"></param>
|
||||
/// <param name="fromBase64"></param>
|
||||
/// <param name="checkNoImagePlaceholder">Will check against all known null image placeholders to avoid writing it</param>
|
||||
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false)
|
||||
/// <param name="chooseBetterImage">If we check cross-reference the current cover for the better option</param>
|
||||
public async Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(url))
|
||||
{
|
||||
@ -504,7 +505,7 @@ public class CoverDbService : ICoverDbService
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(person.CoverImage))
|
||||
if (!string.IsNullOrEmpty(person.CoverImage) && chooseBetterImage)
|
||||
{
|
||||
var existingPath = Path.Combine(_directoryService.CoverImageDirectory, person.CoverImage);
|
||||
var betterImage = existingPath.GetBetterImage(tempFullPath)!;
|
||||
|
||||
@ -331,9 +331,16 @@ public class ProcessSeries : IProcessSeries
|
||||
series.Metadata.Summary = firstChapter.Summary;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(firstChapter?.Language) && !series.Metadata.LanguageLocked)
|
||||
if (!series.Metadata.LanguageLocked)
|
||||
{
|
||||
series.Metadata.Language = firstChapter.Language;
|
||||
if (!string.IsNullOrEmpty(firstChapter?.Language))
|
||||
{
|
||||
series.Metadata.Language = firstChapter.Language;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(library.DefaultLanguage))
|
||||
{
|
||||
series.Metadata.Language = library.DefaultLanguage;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(firstChapter?.WebLinks) && library.InheritWebLinksFromFirstChapter)
|
||||
|
||||
@ -8,19 +8,20 @@ if [ ${#migrations[@]} -lt 2 ]; then
|
||||
fi
|
||||
|
||||
second_last=$(basename "${migrations[1]}" .cs)
|
||||
|
||||
last=$(basename "${migrations[0]}" .cs)
|
||||
last_name=$(echo "$last" | sed 's/^[0-9]*_//')
|
||||
|
||||
new_name=${1:-$last_name}
|
||||
|
||||
echo "Rolling back to: $second_last"
|
||||
echo "Removing and re-adding: $last_name"
|
||||
echo "Removing $last_name and re-adding as $new_name"
|
||||
read -p "Continue? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
dotnet ef database update "$second_last" && \
|
||||
dotnet ef migrations remove && \
|
||||
dotnet ef migrations add "$last_name"
|
||||
dotnet ef migrations add "$new_name"
|
||||
else
|
||||
echo "Cancelled"
|
||||
exit 0
|
||||
|
||||
@ -19,9 +19,9 @@ public static class Configuration
|
||||
public const string DefaultOidcClientId = "kavita";
|
||||
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||
|
||||
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
||||
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("LOCAL_KAVITA_PLUS") == "TRUE"
|
||||
? "http://localhost:5020" : "https://plus.kavitareader.com";
|
||||
public static readonly string StatsApiUrl = "https://stats.kavitareader.com";
|
||||
public const string StatsApiUrl = "https://stats.kavitareader.com";
|
||||
|
||||
public static int Port
|
||||
{
|
||||
|
||||
@ -35,6 +35,7 @@ export interface Library {
|
||||
removePrefixForSortName: boolean;
|
||||
collapseSeriesRelationships: boolean;
|
||||
inheritWebLinksFromFirstChapter: boolean;
|
||||
defaultLanguage: string;
|
||||
libraryFileTypes: Array<FileTypeGroup>;
|
||||
excludePatterns: Array<string>;
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import {PageLayoutMode} from '../page-layout-mode';
|
||||
import {SiteTheme} from './site-theme';
|
||||
import {HighlightSlot} from "../../book-reader/_models/annotations/highlight-slot";
|
||||
import {AgeRating} from "../metadata/age-rating";
|
||||
import {KeyCode} from "../../_services/key-bind.service";
|
||||
|
||||
export interface Preferences {
|
||||
|
||||
@ -16,6 +17,7 @@ export interface Preferences {
|
||||
bookReaderHighlightSlots: HighlightSlot[];
|
||||
colorScapeEnabled: boolean;
|
||||
dataSaver: boolean;
|
||||
customKeyBinds: Partial<Record<KeyBindTarget, KeyBind[]>>;
|
||||
|
||||
// Kavita+
|
||||
aniListScrobblingEnabled: boolean;
|
||||
@ -34,3 +36,27 @@ export interface SocialPreferences {
|
||||
socialIncludeUnknowns: boolean;
|
||||
}
|
||||
|
||||
export interface KeyBind {
|
||||
meta?: boolean;
|
||||
control?: boolean;
|
||||
alt?: boolean;
|
||||
shift?: boolean;
|
||||
controllerSequence?: readonly string[];
|
||||
key: KeyCode;
|
||||
}
|
||||
|
||||
export enum KeyBindTarget {
|
||||
NavigateToSettings = 'NavigateToSettings',
|
||||
OpenSearch = 'OpenSearch',
|
||||
NavigateToScrobbling = 'NavigateToScrobbling',
|
||||
|
||||
ToggleFullScreen = 'ToggleFullScreen',
|
||||
BookmarkPage = 'BookmarkPage',
|
||||
OpenHelp = 'OpenHelp',
|
||||
GoTo = "GoTo",
|
||||
ToggleMenu = 'ToggleMenu',
|
||||
PageLeft = 'PageLeft',
|
||||
PageRight = 'PageRight',
|
||||
Escape = 'Escape',
|
||||
}
|
||||
|
||||
|
||||
40
UI/Web/src/app/_pipes/key-bind.pipe.ts
Normal file
40
UI/Web/src/app/_pipes/key-bind.pipe.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {KeyBind} from "../_models/preferences/preferences";
|
||||
import {KeyCode} from "../_services/key-bind.service";
|
||||
|
||||
@Pipe({
|
||||
name: 'keyBind'
|
||||
})
|
||||
export class KeyBindPipe implements PipeTransform {
|
||||
|
||||
private readonly customMappings: Partial<Record<KeyCode, string>> = {
|
||||
[KeyCode.ArrowDown]: '↓',
|
||||
[KeyCode.ArrowUp]: '↑',
|
||||
[KeyCode.ArrowLeft]: '⇽',
|
||||
[KeyCode.ArrowRight]: '⇾',
|
||||
[KeyCode.Space]: 'space',
|
||||
} as const;
|
||||
|
||||
transform(keyBind: KeyBind | undefined): string {
|
||||
if (!keyBind) return '';
|
||||
|
||||
if (keyBind.controllerSequence) {
|
||||
return keyBind.controllerSequence.join('+');
|
||||
}
|
||||
|
||||
let keys: string[] = [];
|
||||
|
||||
if (keyBind.control) keys.push('Ctrl');
|
||||
if (keyBind.shift) keys.push('Shift');
|
||||
if (keyBind.alt) keys.push('Alt');
|
||||
|
||||
// TODO: Use new device code after progress merge?
|
||||
const isMac = navigator.platform.includes('Mac');
|
||||
if (keyBind.meta) keys.push(isMac ? '⌘' : 'Win');
|
||||
|
||||
keys.push(this.customMappings[keyBind.key] ?? keyBind.key.toUpperCase())
|
||||
|
||||
return keys.join('+')
|
||||
}
|
||||
|
||||
}
|
||||
44
UI/Web/src/app/_pipes/keybind-setting-description.pipe.ts
Normal file
44
UI/Web/src/app/_pipes/keybind-setting-description.pipe.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {KeyBindTarget} from "../_models/preferences/preferences";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'keybindSettingDescription'
|
||||
})
|
||||
export class KeybindSettingDescriptionPipe implements PipeTransform {
|
||||
|
||||
prefix = 'keybind-setting-description-pipe';
|
||||
transform(value: KeyBindTarget) {
|
||||
switch (value) {
|
||||
case KeyBindTarget.NavigateToSettings:
|
||||
return this.create('key-bind-title-navigate-to-settings', 'key-bind-tooltip-navigate-to-settings');
|
||||
case KeyBindTarget.OpenSearch:
|
||||
return this.create('key-bind-title-open-search', 'key-bind-tooltip-open-search');
|
||||
case KeyBindTarget.NavigateToScrobbling:
|
||||
return this.create('key-bind-title-navigate-to-scrobbling', 'key-bind-tooltip-navigate-to-scrobbling');
|
||||
case KeyBindTarget.ToggleFullScreen:
|
||||
return this.create('key-bind-title-toggle-fullscreen', 'key-bind-tooltip-toggle-fullscreen');
|
||||
case KeyBindTarget.BookmarkPage:
|
||||
return this.create('key-bind-title-bookmark-page', 'key-bind-tooltip-bookmark-page');
|
||||
case KeyBindTarget.OpenHelp:
|
||||
return this.create('key-bind-title-open-help', 'key-bind-tooltip-open-help');
|
||||
case KeyBindTarget.GoTo:
|
||||
return this.create('key-bind-title-go-to', 'key-bind-tooltip-go-to');
|
||||
case KeyBindTarget.ToggleMenu:
|
||||
return this.create('key-bind-title-toggle-menu', 'key-bind-tooltip-toggle-menu');
|
||||
case KeyBindTarget.PageLeft:
|
||||
return this.create('key-bind-title-page-left', 'key-bind-tooltip-page-left');
|
||||
case KeyBindTarget.PageRight:
|
||||
return this.create('key-bind-title-page-right', 'key-bind-tooltip-page-right');
|
||||
case KeyBindTarget.Escape:
|
||||
return this.create('key-bind-title-escape', 'key-bind-tooltip-escape');
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private create(titleKey: string, tooltipKey: string) {
|
||||
|
||||
return {title: translate(`${this.prefix}.${titleKey}`), tooltip: translate(`${this.prefix}.${tooltipKey}`)}
|
||||
}
|
||||
|
||||
}
|
||||
@ -69,6 +69,7 @@ export class AccountService {
|
||||
|
||||
public readonly currentUserSignal = toSignal(this.currentUser$);
|
||||
public readonly userId = computed(() => this.currentUserSignal()?.id);
|
||||
public readonly isReadOnly = computed(() => this.currentUserSignal()?.roles.includes(Role.ReadOnly) ?? true);
|
||||
|
||||
/**
|
||||
* SetTimeout handler for keeping track of refresh token call
|
||||
|
||||
146
UI/Web/src/app/_services/game-pad.service.ts
Normal file
146
UI/Web/src/app/_services/game-pad.service.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import {Injectable, signal} from '@angular/core';
|
||||
import {Subject} from "rxjs";
|
||||
|
||||
interface GamePadKeyEvent {
|
||||
/**
|
||||
* Buttons currently pressed
|
||||
*/
|
||||
pressedButtons: readonly GamePadButtonKey[];
|
||||
/**
|
||||
* If the event is keydown, all newly added buttons
|
||||
*/
|
||||
newButtons?: readonly GamePadButtonKey[];
|
||||
/**
|
||||
* If the event is keyup, all removed buttons
|
||||
*/
|
||||
removedButtons?: readonly GamePadButtonKey[];
|
||||
}
|
||||
|
||||
export enum GamePadButtonKey {
|
||||
A = 'A',
|
||||
B = 'B',
|
||||
X = 'X',
|
||||
Y = 'Y',
|
||||
LB = 'LB',
|
||||
RB = 'RB',
|
||||
LT = 'LT',
|
||||
RT = 'RT',
|
||||
Back = 'Back',
|
||||
Start = 'Start',
|
||||
AxisLeft = 'Axis-Left', // Left Stick Button
|
||||
AxisRight = 'Axis-Right', // Right Stick Button
|
||||
DPadUp = 'DPad-Up',
|
||||
DPadDown = 'DPad-Down',
|
||||
DPadLeft = 'DPad-Left',
|
||||
DPadRight = 'DPad-Right',
|
||||
Power = 'Power', // Guide / Home / Xbox Button
|
||||
}
|
||||
|
||||
/**
|
||||
* Button order follows W3C standard mapping:
|
||||
* https://www.w3.org/TR/gamepad/#remapping
|
||||
*/
|
||||
export const GAMEPAD_BUTTON_KEYS: readonly GamePadButtonKey[] = [
|
||||
GamePadButtonKey.A,
|
||||
GamePadButtonKey.B,
|
||||
GamePadButtonKey.X,
|
||||
GamePadButtonKey.Y,
|
||||
GamePadButtonKey.LB,
|
||||
GamePadButtonKey.RB,
|
||||
GamePadButtonKey.LT,
|
||||
GamePadButtonKey.RT,
|
||||
GamePadButtonKey.Back,
|
||||
GamePadButtonKey.Start,
|
||||
GamePadButtonKey.AxisLeft,
|
||||
GamePadButtonKey.AxisRight,
|
||||
GamePadButtonKey.DPadUp,
|
||||
GamePadButtonKey.DPadDown,
|
||||
GamePadButtonKey.DPadLeft,
|
||||
GamePadButtonKey.DPadRight,
|
||||
GamePadButtonKey.Power,
|
||||
];
|
||||
|
||||
|
||||
/**
|
||||
* GamePadService provides a wrapper around the native GamePad browser API as it has a bad DX
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GamePadService {
|
||||
|
||||
protected readonly _gamePads = signal<Set<Gamepad>>(new Set());
|
||||
public readonly gamePads = this._gamePads.asReadonly();
|
||||
|
||||
private readonly keyUpEvents = new Subject<GamePadKeyEvent>();
|
||||
public readonly keyUpEvents$ = this.keyUpEvents.asObservable();
|
||||
private readonly keyDownEvents = new Subject<GamePadKeyEvent>();
|
||||
public readonly keyDownEvents$ = this.keyDownEvents.asObservable();
|
||||
|
||||
private lastState = new Map<number, readonly GamePadButtonKey[]>();
|
||||
private pollId?: number;
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
|
||||
const startLoop = this.gamePads().size === 0;
|
||||
|
||||
this._gamePads.update(s => new Set(s).add(e.gamepad));
|
||||
|
||||
if (startLoop) {
|
||||
this.poll();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
|
||||
this._gamePads.update(s => {
|
||||
const newSet = new Set(s);
|
||||
newSet.delete(e.gamepad);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
this.lastState.delete(e.gamepad.index);
|
||||
if (this.gamePads().size == 0 && this.pollId) {
|
||||
cancelAnimationFrame(this.pollId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private poll() {
|
||||
if (this.gamePads().size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const gamePad of this.gamePads()) {
|
||||
const pressed: GamePadButtonKey[] = [];
|
||||
|
||||
for (let idx = 0; idx < gamePad.buttons.length; idx++) {
|
||||
if (gamePad.buttons[idx].pressed) {
|
||||
pressed.push(GAMEPAD_BUTTON_KEYS[idx]);
|
||||
}
|
||||
}
|
||||
|
||||
const last = this.lastState.get(gamePad.index) ?? [];
|
||||
const newButtons = pressed.filter(btn => !last.includes(btn));
|
||||
const removedButtons = last.filter(btn => !pressed.includes(btn));
|
||||
|
||||
if (newButtons.length > 0) {
|
||||
this.keyDownEvents.next({
|
||||
pressedButtons: [...pressed],
|
||||
newButtons: [...newButtons],
|
||||
});
|
||||
}
|
||||
|
||||
if (removedButtons.length > 0) {
|
||||
this.keyUpEvents.next({
|
||||
pressedButtons: [...pressed],
|
||||
removedButtons: [...removedButtons],
|
||||
});
|
||||
}
|
||||
|
||||
this.lastState.set(gamePad.index, [...pressed]);
|
||||
}
|
||||
|
||||
this.pollId = requestAnimationFrame(() => this.poll());
|
||||
}
|
||||
|
||||
}
|
||||
441
UI/Web/src/app/_services/key-bind.service.ts
Normal file
441
UI/Web/src/app/_services/key-bind.service.ts
Normal file
@ -0,0 +1,441 @@
|
||||
import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core';
|
||||
import {AccountService, Role} from "./account.service";
|
||||
import {KeyBind, KeyBindTarget} from "../_models/preferences/preferences";
|
||||
import {DOCUMENT} from "@angular/common";
|
||||
import {filter, finalize, Observable, of, Subject, tap, withLatestFrom} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {map} from "rxjs/operators";
|
||||
import {GamePadService} from "./game-pad.service";
|
||||
|
||||
/**
|
||||
* Codes as returned by KeyBoardEvent.key.toLowerCase()
|
||||
*/
|
||||
export enum KeyCode {
|
||||
KeyA = "a",
|
||||
KeyB = "b",
|
||||
KeyC = "c",
|
||||
KeyD = "d",
|
||||
KeyE = "e",
|
||||
KeyF = "f",
|
||||
KeyG = "g",
|
||||
KeyH = "h",
|
||||
KeyI = "i",
|
||||
KeyJ = "j",
|
||||
KeyK = "k",
|
||||
KeyL = "l",
|
||||
KeyM = "m",
|
||||
KeyN = "n",
|
||||
KeyO = "o",
|
||||
KeyP = "p",
|
||||
KeyQ = "q",
|
||||
KeyR = "r",
|
||||
KeyS = "s",
|
||||
KeyT = "t",
|
||||
KeyU = "u",
|
||||
KeyV = "v",
|
||||
KeyW = "w",
|
||||
KeyX = "x",
|
||||
KeyY = "y",
|
||||
KeyZ = "z",
|
||||
|
||||
|
||||
Digit0 = "0",
|
||||
Digit1 = "1",
|
||||
Digit2 = "2",
|
||||
Digit3 = "3",
|
||||
Digit4 = "4",
|
||||
Digit5 = "5",
|
||||
Digit6 = "6",
|
||||
Digit7 = "7",
|
||||
Digit8 = "8",
|
||||
Digit9 = "9",
|
||||
|
||||
ArrowUp = "arrowup",
|
||||
ArrowDown = "arrowdown",
|
||||
ArrowLeft = "arrowleft",
|
||||
ArrowRight = "arrowright",
|
||||
|
||||
Comma = ',',
|
||||
Space = ' ',
|
||||
Escape = 'escape',
|
||||
|
||||
Control = "control",
|
||||
Alt = "alt",
|
||||
Shift = "shift",
|
||||
Meta = "meta",
|
||||
|
||||
Empty = '',
|
||||
}
|
||||
|
||||
/**
|
||||
* KeyCodes we consider modifiers
|
||||
*/
|
||||
export const ModifierKeyCodes: KeyCode[] = [
|
||||
KeyCode.Control,
|
||||
KeyCode.Alt,
|
||||
KeyCode.Shift,
|
||||
KeyCode.Meta,
|
||||
];
|
||||
|
||||
/**
|
||||
* Emitted if a keybind has been recorded
|
||||
*/
|
||||
export interface KeyBindEvent {
|
||||
/**
|
||||
* Target of the event
|
||||
*/
|
||||
target: KeyBindTarget;
|
||||
/**
|
||||
* Overriding this value must be done in the sync callback of your
|
||||
* observable. When true after all observables have completed, will cancel the event that triggered it
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
triggered: boolean;
|
||||
/**
|
||||
* If the original event's target was editable. This is only relevant for KeyBoard events, GamePad events do not
|
||||
* contain this information
|
||||
*/
|
||||
inEditableElement: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add any keybinds in this array which cannot be used users ever
|
||||
* Example: Page refresh
|
||||
*/
|
||||
const ReservedKeyBinds: KeyBind[] = [
|
||||
{control: true, key: KeyCode.KeyR},
|
||||
{meta: true, key: KeyCode.KeyR},
|
||||
];
|
||||
|
||||
/**
|
||||
* This record should hold all KeyBinds Kavita has to offer, with their default combination(s).
|
||||
* To add a new keybind to the system, add it here and in the backend enum. Add it to the KeyBindGroups
|
||||
* array to be displayed on the settings page
|
||||
*/
|
||||
export const DefaultKeyBinds: Readonly<Record<KeyBindTarget, KeyBind[]>> = {
|
||||
[KeyBindTarget.NavigateToSettings]: [{meta: true, key: KeyCode.Comma}],
|
||||
[KeyBindTarget.OpenSearch]: [{control: true, key: KeyCode.KeyK}, {meta: true, key: KeyCode.KeyK}],
|
||||
[KeyBindTarget.NavigateToScrobbling]: [],
|
||||
[KeyBindTarget.ToggleFullScreen]: [{key: KeyCode.KeyF}],
|
||||
[KeyBindTarget.BookmarkPage]: [{key: KeyCode.KeyB, control: true}],
|
||||
[KeyBindTarget.OpenHelp]: [{key: KeyCode.KeyH}],
|
||||
[KeyBindTarget.GoTo]: [{key: KeyCode.KeyG}],
|
||||
[KeyBindTarget.ToggleMenu]: [{key: KeyCode.Space}],
|
||||
[KeyBindTarget.PageLeft]: [{key: KeyCode.ArrowLeft}, {key: KeyCode.ArrowUp}],
|
||||
[KeyBindTarget.PageRight]: [{key: KeyCode.ArrowRight}, {key: KeyCode.ArrowDown}],
|
||||
[KeyBindTarget.Escape]: [{key: KeyCode.Escape}]
|
||||
} as const;
|
||||
|
||||
type KeyBindGroup = {
|
||||
title: string,
|
||||
elements: {
|
||||
target: KeyBindTarget,
|
||||
roles?: Role[];
|
||||
restrictedRoles?: Role[],
|
||||
kavitaPlus?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const KeyBindGroups: KeyBindGroup[] = [
|
||||
{
|
||||
title: 'global-header',
|
||||
elements: [
|
||||
{target: KeyBindTarget.NavigateToSettings},
|
||||
{target: KeyBindTarget.OpenSearch},
|
||||
{target: KeyBindTarget.NavigateToScrobbling, kavitaPlus: true},
|
||||
{target: KeyBindTarget.Escape},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'readers-header',
|
||||
elements: [
|
||||
{target: KeyBindTarget.ToggleFullScreen},
|
||||
{target: KeyBindTarget.BookmarkPage},
|
||||
{target: KeyBindTarget.OpenHelp},
|
||||
{target: KeyBindTarget.GoTo},
|
||||
{target: KeyBindTarget.ToggleMenu},
|
||||
{target: KeyBindTarget.PageRight},
|
||||
{target: KeyBindTarget.PageLeft},
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
interface RegisterListenerOptions {
|
||||
/**
|
||||
* @default false
|
||||
*/
|
||||
fireInEditable?: boolean;
|
||||
/**
|
||||
* @default of(true)
|
||||
*/
|
||||
condition$?: Observable<boolean>;
|
||||
/**
|
||||
* @default true
|
||||
*/
|
||||
markAsTriggered?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class KeyBindService {
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly gamePadService = inject(GamePadService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
/**
|
||||
* Global disable switch for the keybind listener. Make sure you enable again after using
|
||||
* so keybinds don't stop working across the app.
|
||||
*/
|
||||
public readonly disabled = signal(false);
|
||||
|
||||
/**
|
||||
* Valid custom keybinds as configured by the authenticated user
|
||||
* @private
|
||||
*/
|
||||
private readonly customKeyBinds = computed(() => {
|
||||
const customKeyBinds = this.accountService.currentUserSignal()?.preferences.customKeyBinds ?? {};
|
||||
return Object.fromEntries(Object.entries(customKeyBinds).filter(([target, _]) => {
|
||||
return DefaultKeyBinds[target as KeyBindTarget] !== undefined; // Filter out unused or old targets
|
||||
}))
|
||||
});
|
||||
|
||||
/**
|
||||
* All key binds for which the target is currently active
|
||||
* @private
|
||||
*/
|
||||
private readonly activeKeyBinds = computed<Record<KeyBindTarget, KeyBind[]>>(() => {
|
||||
const customKeyBindsRaw = this.customKeyBinds();
|
||||
const activeTargets = this.activeTargetsSet();
|
||||
|
||||
const customKeyBinds: Partial<Record<KeyBindTarget, KeyBind[]>> = {};
|
||||
for (const [target, combos] of Object.entries(customKeyBindsRaw) as [KeyBindTarget, KeyBind[]][]) {
|
||||
if (activeTargets.has(target)) {
|
||||
customKeyBinds[target] = combos.filter(combo => !this.isReservedKeyBind(combo));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...DefaultKeyBinds,
|
||||
...customKeyBinds,
|
||||
} satisfies Record<KeyBindTarget, readonly KeyBind[]>;
|
||||
});
|
||||
|
||||
/**
|
||||
* A record of all possible keybinds in Kavita, as configured by the user
|
||||
*/
|
||||
public readonly allKeyBinds = computed<Record<KeyBindTarget, KeyBind[]>>(() => {
|
||||
const customKeyBinds = this.customKeyBinds();
|
||||
|
||||
return {
|
||||
...DefaultKeyBinds,
|
||||
...customKeyBinds,
|
||||
} satisfies Record<KeyBindTarget, readonly KeyBind[]>;
|
||||
});
|
||||
|
||||
/**
|
||||
* A set of all keys used in all keybinds, other keys should not be tracked
|
||||
* @private
|
||||
*/
|
||||
private readonly listenedKeys = computed(() => {
|
||||
const keyBinds = this.activeKeyBinds();
|
||||
const combos = Object.values(keyBinds);
|
||||
const allKeys = combos.flatMap(c => c).flatMap(c => c).map(kb => kb.key);
|
||||
return new Set(allKeys);
|
||||
});
|
||||
|
||||
private readonly activeTargets = signal<KeyBindTarget[]>([]);
|
||||
private readonly activeTargetsSet = computed(() => new Set(this.activeTargets()));
|
||||
|
||||
/**
|
||||
* We do not allow subscribing to the events$ directly, as there is some extra state management for performance
|
||||
* reasons. See registerListener for details
|
||||
* @private
|
||||
*/
|
||||
private readonly eventsSubject = new Subject<KeyBindEvent>();
|
||||
private readonly events$ = this.eventsSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
// We use keydown as to intercept before native browser keybinds, in case we want to cancel the event
|
||||
this.document.addEventListener('keydown', e => this.handleKeyEvent(e));
|
||||
|
||||
this.gamePadService.keyDownEvents$.pipe(
|
||||
map(e => {
|
||||
return {
|
||||
key: KeyCode.Empty,
|
||||
controllerSequence: e.pressedButtons,
|
||||
} as KeyBind;
|
||||
}),
|
||||
tap(kb => this.checkForKeyBind(kb)),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
private handleKeyEvent(event: KeyboardEvent) {
|
||||
if (this.disabled()) return;
|
||||
|
||||
const eventKey = event.key.toLowerCase() as KeyCode;
|
||||
|
||||
if (!this.listenedKeys().has(eventKey)) return;
|
||||
|
||||
const activeKeyBind: KeyBind = {
|
||||
key: eventKey,
|
||||
control: event.ctrlKey,
|
||||
meta: event.metaKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
};
|
||||
|
||||
this.checkForKeyBind(activeKeyBind, event);
|
||||
}
|
||||
|
||||
private checkForKeyBind(activeKeyBind: KeyBind, event?: KeyboardEvent) {
|
||||
const activeKeyBinds = this.activeKeyBinds();
|
||||
for (const [target, keybinds] of Object.entries(activeKeyBinds)) {
|
||||
for (const keybind of keybinds) {
|
||||
|
||||
if (!this.areKeyBindsEqual(activeKeyBind, keybind)) continue;
|
||||
|
||||
const keyBindEvent: KeyBindEvent = {
|
||||
target: target as KeyBindTarget,
|
||||
triggered: false,
|
||||
inEditableElement: event ? this.isEditableTarget(event.target) : false,
|
||||
};
|
||||
|
||||
this.eventsSubject.next(keyBindEvent);
|
||||
|
||||
if (event && keyBindEvent.triggered) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key events while in this target should be ignored
|
||||
* @param target
|
||||
* @private
|
||||
*/
|
||||
private isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
if (target instanceof HTMLInputElement) return true;
|
||||
if (target instanceof HTMLTextAreaElement) return true;
|
||||
|
||||
return target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for targets. When a match is found will set KeyBindEvent#triggered to true
|
||||
* @param destroyRef$ destroy ref used for lifetime management
|
||||
* @param callback
|
||||
* @param targetFilter
|
||||
* @param options
|
||||
*/
|
||||
public registerListener(
|
||||
destroyRef$: DestroyRef,
|
||||
callback: (e: KeyBindEvent) => void,
|
||||
targetFilter: KeyBindTarget[],
|
||||
options?: RegisterListenerOptions,
|
||||
) {
|
||||
const {
|
||||
fireInEditable = false,
|
||||
condition$ = of(true),
|
||||
markAsTriggered = true,
|
||||
} = options ?? {};
|
||||
|
||||
this.activeTargets.update(s => [...s, ...targetFilter]);
|
||||
|
||||
this.events$.pipe(
|
||||
takeUntilDestroyed(destroyRef$),
|
||||
filter(e => !e.inEditableElement || fireInEditable),
|
||||
filter(e => targetFilter.includes(e.target)),
|
||||
withLatestFrom(condition$),
|
||||
filter(([_, ok]) => ok),
|
||||
map(([e, _]) => e),
|
||||
tap(e => {
|
||||
if (markAsTriggered) {
|
||||
e.triggered = true; // Set before callback so consumers may override
|
||||
}
|
||||
|
||||
callback(e);
|
||||
}),
|
||||
finalize(() => { // Remove all targets when the consumer has finished
|
||||
this.activeTargets.update(targets => {
|
||||
const updated = [...targets];
|
||||
// Remove only once in case others have registered the same target
|
||||
targetFilter.forEach(target => this.removeOnce(updated, target));
|
||||
return updated;
|
||||
});
|
||||
}),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the first occurrence of element in the array
|
||||
* @param array
|
||||
* @param element
|
||||
* @private
|
||||
*/
|
||||
private removeOnce<T>(array: T[], element: T) {
|
||||
const index = array.indexOf(element);
|
||||
if (index !== -1) {
|
||||
array.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the keybinds are semantically equal
|
||||
* @param k1
|
||||
* @param k2
|
||||
*/
|
||||
public areKeyBindsEqual(k1: KeyBind, k2: KeyBind) {
|
||||
// If a controller sequence is present on either, it takes full and the only priority
|
||||
if (k1.controllerSequence || k2.controllerSequence) {
|
||||
return k1.controllerSequence?.every(k => k2.controllerSequence?.includes(k)) || false;
|
||||
}
|
||||
|
||||
return (
|
||||
(k1.alt ?? false) === (k2.alt ?? false) &&
|
||||
(k1.shift ?? false) === (k2.shift ?? false) &&
|
||||
(k1.control ?? false) === (k2.control ?? false) &&
|
||||
(k1.meta ?? false) === (k2.meta ?? false) &&
|
||||
k1.key === k2.key
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the given keybind against the ReservedKeyBinds list. If true, keybind should be considered invalid and unusable
|
||||
* @param keyBind
|
||||
*/
|
||||
public isReservedKeyBind(keyBind: KeyBind) {
|
||||
for (let reservedKeyBind of ReservedKeyBinds) {
|
||||
if (this.areKeyBindsEqual(reservedKeyBind, keyBind)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given keyBinds are equal to the default ones for the target, and can be skipped when saving to user preferences
|
||||
* @param target
|
||||
* @param keyBinds
|
||||
*/
|
||||
public isDefaultKeyBinds(target: KeyBindTarget, keyBinds: KeyBind[]) {
|
||||
const defaultKeyBinds = DefaultKeyBinds[target];
|
||||
if (!defaultKeyBinds) {
|
||||
throw Error("Could not find default keybinds for " + target)
|
||||
}
|
||||
|
||||
if (defaultKeyBinds.length !== keyBinds.length) return false;
|
||||
|
||||
return keyBinds.every(keyBind =>
|
||||
defaultKeyBinds.some(defaultKeyBind => this.areKeyBindsEqual(defaultKeyBind, keyBind))
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@ -78,7 +78,7 @@ export class LibraryService {
|
||||
}
|
||||
|
||||
hasFilesAtRoot(roots: Array<string>) {
|
||||
return this.httpClient.post<{[key: string]: boolean}>(this.baseUrl + 'library/has-files-at-root', {roots});
|
||||
return this.httpClient.post<Array<string>>(this.baseUrl + 'library/has-files-at-root', {roots});
|
||||
}
|
||||
|
||||
getJumpBar(libraryId: number) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {DestroyRef, inject, Injectable, Renderer2, RendererFactory2, RendererStyleFlags2} from '@angular/core';
|
||||
import {filter, ReplaySubject, take} from 'rxjs';
|
||||
import {filter, ReplaySubject, take, tap} from 'rxjs';
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
@ -129,8 +129,8 @@ export class NavService {
|
||||
return this.httpClient.get<Array<SideNavStream>>(this.baseUrl + 'stream/sidenav?visibleOnly=' + visibleOnly);
|
||||
}
|
||||
|
||||
updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition}, TextResonse);
|
||||
updateSideNavStreamPosition(streamName: string, sideNavStreamId: number, fromPosition: number, toPosition: number, positionIncludesInvisible: boolean = true) {
|
||||
return this.httpClient.post(this.baseUrl + 'stream/update-sidenav-position', {streamName, id: sideNavStreamId, fromPosition, toPosition, positionIncludesInvisible}, TextResonse);
|
||||
}
|
||||
|
||||
updateSideNavStream(stream: SideNavStream) {
|
||||
|
||||
@ -7,7 +7,7 @@ import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {Chapter} from "../../_models/chapter";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
|
||||
import {setupLanguageSettings, TypeaheadSettings} from "../../typeahead/_models/typeahead-settings";
|
||||
import {Tag} from "../../_models/tag";
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {Person, PersonRole} from "../../_models/metadata/person";
|
||||
@ -125,14 +125,13 @@ export class EditChapterModalComponent implements OnInit {
|
||||
coverImageReset = false;
|
||||
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> | null = null;
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
|
||||
tags: Tag[] = [];
|
||||
genres: Genre[] = [];
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
validLanguages: Array<Language> = [];
|
||||
|
||||
tasks = this.actionFactoryService.getActionablesForSettingsPage(this.actionFactoryService.getChapterActions(this.runTask.bind(this)), blackList);
|
||||
/**
|
||||
@ -189,10 +188,9 @@ export class EditChapterModalComponent implements OnInit {
|
||||
|
||||
this.metadataService.getAllValidLanguages().pipe(
|
||||
tap(validLanguages => {
|
||||
this.validLanguages = validLanguages;
|
||||
this.languageSettings = setupLanguageSettings(true, this.utilityService, validLanguages, this.chapter.language);
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
switchMap(_ => this.setupLanguageTypeahead())
|
||||
).subscribe();
|
||||
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
@ -313,7 +311,6 @@ export class EditChapterModalComponent implements OnInit {
|
||||
this.setupTagSettings(),
|
||||
this.setupGenreTypeahead(),
|
||||
this.setupPersonTypeahead(),
|
||||
this.setupLanguageTypeahead()
|
||||
]).subscribe(results => {
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
@ -383,34 +380,6 @@ export class EditChapterModalComponent implements OnInit {
|
||||
return of(true);
|
||||
}
|
||||
|
||||
setupLanguageTypeahead() {
|
||||
this.languageSettings.minCharacters = 0;
|
||||
this.languageSettings.multiple = false;
|
||||
this.languageSettings.id = 'language';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.showLocked = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
|
||||
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.chapter.language);
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
return of(true);
|
||||
}
|
||||
|
||||
|
||||
updateFromPreset(id: string, presetField: Array<Person> | undefined, role: PersonRole) {
|
||||
const personSettings = this.createBlankPersonSettings(id, role)
|
||||
|
||||
@ -55,6 +55,9 @@
|
||||
@if (formControl.errors && formControl.errors.requiredIf) {
|
||||
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
|
||||
}
|
||||
@if (formControl.errors && formControl.errors.requiredIfOtherInvalid) {
|
||||
<div>{{t('other-field-invalid', {other: formControl.errors.requiredIfOtherInvalid.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -67,7 +70,7 @@
|
||||
@if (settingsForm.get('secret'); as formControl) {
|
||||
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value | defaultValue}}
|
||||
{{formControl.value | slice:0:40 | defaultValue}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
|
||||
@ -79,6 +82,9 @@
|
||||
@if (formControl.errors && formControl.errors.requiredIf) {
|
||||
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
|
||||
}
|
||||
@if (formControl.errors && formControl.errors.requiredIfOtherInvalid) {
|
||||
<div>{{t('other-field-invalid', {other: formControl.errors.requiredIfOtherInvalid.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ import {
|
||||
SettingMultiTextFieldComponent
|
||||
} from "../../settings/_components/setting-multi-text-field/setting-multi-text-field.component";
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {SlicePipe} from "@angular/common";
|
||||
|
||||
type OidcFormGroup = FormGroup<{
|
||||
autoLogin: FormControl<boolean>;
|
||||
@ -75,7 +76,8 @@ type OidcFormGroup = FormGroup<{
|
||||
SafeHtmlPipe,
|
||||
DefaultValuePipe,
|
||||
SettingMultiCheckBox,
|
||||
SettingMultiTextFieldComponent
|
||||
SettingMultiTextFieldComponent,
|
||||
SlicePipe
|
||||
],
|
||||
templateUrl: './manage-open-idconnect.component.html',
|
||||
styleUrl: './manage-open-idconnect.component.scss',
|
||||
@ -166,8 +168,16 @@ export class ManageOpenIDConnectComponent implements OnInit {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
save(showConfirmation: boolean = false) {
|
||||
if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings()) return;
|
||||
save(showToasts: boolean = false) {
|
||||
if (!this.settingsForm.valid) {
|
||||
if (showToasts) {
|
||||
this.toastr.error(translate('errors.invalid-form'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.serverSettings || !this.oidcSettings()) return;
|
||||
|
||||
const newSettings = this.packData();
|
||||
this.settingsService.updateServerSettings(newSettings).subscribe({
|
||||
@ -176,7 +186,7 @@ export class ManageOpenIDConnectComponent implements OnInit {
|
||||
this.oidcSettings.set(data.oidcConfig);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (showConfirmation) {
|
||||
if (showToasts) {
|
||||
this.toastr.success(translate('manage-oidc-connect.save-success'))
|
||||
}
|
||||
},
|
||||
@ -219,7 +229,9 @@ export class ManageOpenIDConnectComponent implements OnInit {
|
||||
const otherControl = this.settingsForm.get(other);
|
||||
if (!otherControl) return null;
|
||||
|
||||
if (otherControl.invalid) return null;
|
||||
if (otherControl.invalid) {
|
||||
return { 'requiredIfOtherInvalid': { 'other': other, 'errors': otherControl.errors } }
|
||||
}
|
||||
|
||||
const v = otherControl.value;
|
||||
if (!v || v.length === 0) return null;
|
||||
|
||||
@ -5,7 +5,6 @@ import {
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
model,
|
||||
@ -18,11 +17,12 @@ import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/
|
||||
import {ReaderService} from "../../../_services/reader.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {KEY_CODES} from "../../../shared/_services/utility.service";
|
||||
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
|
||||
import {Annotation} from "../../_models/annotations/annotation";
|
||||
import {isMobileChromium} from "../../../_helpers/browser";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
|
||||
enum BookLineOverlayMode {
|
||||
None = 0,
|
||||
@ -64,18 +64,7 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
private readonly epubMenuService = inject(EpubReaderMenuService);
|
||||
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.ESC_KEY) {
|
||||
this.reset();
|
||||
this.cdRef.markForCheck();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
@ -90,6 +79,12 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
// Fallback to mouse/touch events
|
||||
this.setupLegacyEventListeners();
|
||||
}
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
() => this.reset(),
|
||||
[KeyBindTarget.Escape],
|
||||
);
|
||||
}
|
||||
|
||||
private setupPointerEventListener(): void {
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
effect,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
model,
|
||||
OnDestroy,
|
||||
@ -31,7 +30,7 @@ import {CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService} from 'sr
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
import {DomSanitizer, SafeHtml, Title} from '@angular/platform-browser';
|
||||
import {BookService} from '../../_services/book.service';
|
||||
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {BookChapterItem} from '../../_models/book-chapter-item';
|
||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||
import {Stack} from 'src/app/shared/data-structures/stack';
|
||||
@ -69,6 +68,8 @@ import {environment} from "../../../../environments/environment";
|
||||
import {LoadPageEvent} from "../_drawers/view-bookmarks-drawer/view-bookmark-drawer.component";
|
||||
import {FontService} from "../../../_services/font.service";
|
||||
import afterFrame from "afterframe";
|
||||
import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
|
||||
|
||||
interface HistoryPoint {
|
||||
@ -158,6 +159,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly layoutService = inject(LayoutMeasurementService);
|
||||
private readonly colorscapeService = inject(ColorscapeService);
|
||||
private readonly fontService = inject(FontService);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
libraryId!: number;
|
||||
seriesId!: number;
|
||||
@ -652,6 +654,56 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
async (e) => {
|
||||
const activeElement = this.document.activeElement as HTMLElement;
|
||||
const isInputFocused = activeElement.tagName === 'INPUT'
|
||||
|| activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.contentEditable === 'true' ||
|
||||
activeElement.closest('.ql-editor'); // Quill editor class
|
||||
|
||||
if (isInputFocused) {
|
||||
e.triggered = false;
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.target) {
|
||||
case KeyBindTarget.PageLeft:
|
||||
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD);
|
||||
break;
|
||||
case KeyBindTarget.PageRight:
|
||||
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS);
|
||||
break;
|
||||
case KeyBindTarget.Escape:
|
||||
const isHighlighting = window.getSelection()?.toString() != '';
|
||||
if (isHighlighting && this.isLineOverlayOpen()) return;
|
||||
|
||||
this.closeReader();
|
||||
break;
|
||||
case KeyBindTarget.GoTo:
|
||||
await this.goToPage();
|
||||
break;
|
||||
case KeyBindTarget.ToggleFullScreen:
|
||||
this.applyFullscreen();
|
||||
break;
|
||||
case KeyBindTarget.ToggleMenu:
|
||||
this.actionBarVisible.update(x => !x);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[KeyBindTarget.PageLeft, KeyBindTarget.PageRight, KeyBindTarget.Escape, KeyBindTarget.GoTo,
|
||||
KeyBindTarget.ToggleFullScreen, KeyBindTarget.ToggleMenu],
|
||||
);
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
() => {
|
||||
this.toggleDrawer();
|
||||
},
|
||||
[KeyBindTarget.NavigateToSettings]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -996,41 +1048,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
async handleKeyPress(event: KeyboardEvent) {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
const isInputFocused = activeElement.tagName === 'INPUT'
|
||||
|| activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.contentEditable === 'true' ||
|
||||
activeElement.closest('.ql-editor'); // Quill editor class
|
||||
|
||||
if (isInputFocused) return;
|
||||
|
||||
switch (event.key) {
|
||||
case KEY_CODES.RIGHT_ARROW:
|
||||
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.FORWARD : PAGING_DIRECTION.BACKWARDS);
|
||||
break;
|
||||
case KEY_CODES.LEFT_ARROW:
|
||||
this.movePage(this.readingDirection() === ReadingDirection.LeftToRight ? PAGING_DIRECTION.BACKWARDS : PAGING_DIRECTION.FORWARD);
|
||||
break;
|
||||
case KEY_CODES.ESC_KEY:
|
||||
const isHighlighting = window.getSelection()?.toString() != '';
|
||||
if (isHighlighting || this.isLineOverlayOpen()) return;
|
||||
|
||||
this.closeReader();
|
||||
break;
|
||||
case KEY_CODES.G:
|
||||
await this.goToPage();
|
||||
break;
|
||||
case KEY_CODES.F:
|
||||
this.applyFullscreen();
|
||||
break;
|
||||
case KEY_CODES.SPACE:
|
||||
this.actionBarVisible.update(x => !x);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onWheel(event: WheelEvent) {
|
||||
// This allows the user to scroll the page horizontally without holding shift
|
||||
if (this.layoutMode() !== BookPageLayoutMode.Default || this.writingStyle() !== WritingStyle.Vertical) {
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
import {concat, forkJoin, Observable, of, tap} from 'rxjs';
|
||||
import {map, switchMap} from 'rxjs/operators';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
|
||||
import {setupLanguageSettings, TypeaheadSettings} from 'src/app/typeahead/_models/typeahead-settings';
|
||||
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
|
||||
import {Genre} from 'src/app/_models/metadata/genre';
|
||||
import {AgeRatingDto} from 'src/app/_models/metadata/age-rating-dto';
|
||||
@ -165,7 +165,7 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
|
||||
// Typeaheads
|
||||
tagsSettings: TypeaheadSettings<Tag> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> = new TypeaheadSettings();
|
||||
languageSettings: TypeaheadSettings<Language> | null = null;
|
||||
peopleSettings: {[PersonRole: string]: TypeaheadSettings<Person>} = {};
|
||||
genreSettings: TypeaheadSettings<Genre> = new TypeaheadSettings();
|
||||
|
||||
@ -173,7 +173,6 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
genres: Genre[] = [];
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
publicationStatuses: Array<PublicationStatusDto> = [];
|
||||
validLanguages: Array<Language> = [];
|
||||
|
||||
metadata!: SeriesMetadata;
|
||||
imageUrls: Array<string> = [];
|
||||
@ -439,33 +438,7 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
return this.metadataService.getAllValidLanguages()
|
||||
.pipe(
|
||||
tap(validLanguages => {
|
||||
this.validLanguages = validLanguages;
|
||||
|
||||
this.languageSettings.minCharacters = 0;
|
||||
this.languageSettings.multiple = false;
|
||||
this.languageSettings.id = 'language';
|
||||
this.languageSettings.unique = true;
|
||||
this.languageSettings.showLocked = true;
|
||||
this.languageSettings.addIfNonExisting = false;
|
||||
this.languageSettings.compareFn = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filter(m.title, filter));
|
||||
}
|
||||
this.languageSettings.compareFnForAdd = (options: Language[], filter: string) => {
|
||||
return options.filter(m => this.utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
this.languageSettings.fetchFn = (filter: string) => of(this.validLanguages)
|
||||
.pipe(map(items => this.languageSettings.compareFn(items, filter)));
|
||||
|
||||
this.languageSettings.selectionCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode == b.isoCode;
|
||||
}
|
||||
|
||||
const l = this.validLanguages.find(l => l.isoCode === this.metadata.language);
|
||||
if (l !== undefined) {
|
||||
this.languageSettings.savedData = l;
|
||||
}
|
||||
this.languageSettings.trackByIdentityFn = (index, value) => value.isoCode;
|
||||
|
||||
this.languageSettings = setupLanguageSettings(true, this.utilityService, validLanguages, this.metadata.language);
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
switchMap(_ => of(true))
|
||||
@ -596,7 +569,6 @@ export class EditSeriesModalComponent implements OnInit {
|
||||
|
||||
updatePerson(persons: Person[], role: PersonRole) {
|
||||
this.metadataService.updatePerson(this.metadata, persons, role);
|
||||
this.metadata.locationLocked = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
[libraryType]="libraryType"
|
||||
[mangaFormat]="series.format"
|
||||
[totalBytes]="size"
|
||||
[releaseYear]="(chapter.releaseDate | utcToLocaleDate)?.getFullYear()"
|
||||
/>
|
||||
|
||||
|
||||
@ -105,7 +106,7 @@
|
||||
@if (chapter.releaseDate !== '0001-01-01T00:00:00' && (libraryType === LibraryType.ComicVine || libraryType === LibraryType.Comic)) {
|
||||
<span class="fw-bold">{{t('release-date-title')}}</span>
|
||||
<div>
|
||||
<a class="dark-exempt btn-icon" href="javascript:void(0);">{{chapter.releaseDate | date: 'shortDate' | defaultDate:'—'}}</a>
|
||||
<a class="dark-exempt btn-icon" href="javascript:void(0);">{{chapter.releaseDate | utcToLocalTime: 'shortDate' | defaultDate:'—'}}</a>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="fw-bold">{{t('cover-artists-title')}}</span>
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {AsyncPipe, DatePipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
|
||||
import {AsyncPipe, DOCUMENT, Location, NgClass, NgStyle} from "@angular/common";
|
||||
import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component";
|
||||
import {LoadingComponent} from "../shared/loading/loading.component";
|
||||
import {
|
||||
@ -61,7 +61,7 @@ import {
|
||||
} from "../series-detail/_components/metadata-detail-row/metadata-detail-row.component";
|
||||
import {DownloadButtonComponent} from "../series-detail/_components/download-button/download-button.component";
|
||||
import {hasAnyCast} from "../_models/common/i-has-cast";
|
||||
import {Breakpoint, UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
|
||||
import {UserBreakpoint, UtilityService} from "../shared/_services/utility.service";
|
||||
import {EVENTS, MessageHubService} from "../_services/message-hub.service";
|
||||
import {CoverUpdateEvent} from "../_models/events/cover-update-event";
|
||||
import {ChapterRemovedEvent} from "../_models/events/chapter-removed-event";
|
||||
@ -79,6 +79,8 @@ import {Rating} from "../_models/rating";
|
||||
import {AnnotationService} from "../_services/annotation.service";
|
||||
import {Annotation} from "../book-reader/_models/annotations/annotation";
|
||||
import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component";
|
||||
import {UtcToLocalTimePipe} from "../_pipes/utc-to-local-time.pipe";
|
||||
import {UtcToLocaleDatePipe} from "../_pipes/utc-to-locale-date.pipe";
|
||||
|
||||
enum TabID {
|
||||
Related = 'related-tab',
|
||||
@ -115,12 +117,13 @@ enum TabID {
|
||||
BadgeExpanderComponent,
|
||||
MetadataDetailRowComponent,
|
||||
DownloadButtonComponent,
|
||||
DatePipe,
|
||||
DefaultDatePipe,
|
||||
CoverImageComponent,
|
||||
ReviewsComponent,
|
||||
ExternalRatingComponent,
|
||||
AnnotationsTabComponent
|
||||
AnnotationsTabComponent,
|
||||
UtcToLocalTimePipe,
|
||||
UtcToLocaleDatePipe
|
||||
],
|
||||
templateUrl: './chapter-detail.component.html',
|
||||
styleUrl: './chapter-detail.component.scss',
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
model,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
Signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
@ -35,11 +36,11 @@ import {
|
||||
import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider';
|
||||
import {animate, state, style, transition, trigger} from '@angular/animations';
|
||||
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgbModal, NgbModalRef, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {ShortcutsModalComponent} from 'src/app/reader-shared/_modals/shortcuts-modal/shortcuts-modal.component';
|
||||
import {Stack} from 'src/app/shared/data-structures/stack';
|
||||
import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {Breakpoint, UtilityService} from 'src/app/shared/_services/utility.service';
|
||||
import {LibraryType} from 'src/app/_models/library/library';
|
||||
import {MangaFormat} from 'src/app/_models/manga-format';
|
||||
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
|
||||
@ -81,6 +82,8 @@ import {
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {ConfirmService} from "../../../shared/confirm.service";
|
||||
import {PageBookmark} from "../../../_models/readers/page-bookmark";
|
||||
import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
|
||||
|
||||
const PREFETCH_PAGES = 10;
|
||||
@ -166,6 +169,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
protected readonly readerService = inject(ReaderService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
protected readonly mangaReaderService = inject(MangaReaderService);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
|
||||
protected readonly KeyDirection = KeyDirection;
|
||||
@ -504,8 +508,59 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return chapterInfo?.chapterTitle || chapterInfo?.subtitle || '';
|
||||
});
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
async (e) => {
|
||||
switch (e.target) {
|
||||
case KeyBindTarget.Escape:
|
||||
if (this.menuOpen) {
|
||||
this.toggleMenu();
|
||||
return;
|
||||
}
|
||||
if (this.shortCutModalOpen()){
|
||||
this.closeShortCutModal();
|
||||
return;
|
||||
}
|
||||
this.closeReader();
|
||||
break;
|
||||
case KeyBindTarget.PageLeft:
|
||||
this.handlePageLeft();
|
||||
break;
|
||||
case KeyBindTarget.PageRight:
|
||||
this.handlePageRight();
|
||||
break;
|
||||
case KeyBindTarget.ToggleFullScreen:
|
||||
this.toggleFullscreen();
|
||||
break;
|
||||
case KeyBindTarget.BookmarkPage:
|
||||
this.bookmarkPage();
|
||||
break;
|
||||
case KeyBindTarget.GoTo:
|
||||
const goToPageNum = await this.promptForPage();
|
||||
if (goToPageNum === null) { return; }
|
||||
this.goToPage(parseInt(goToPageNum.trim(), 10));
|
||||
break;
|
||||
case KeyBindTarget.ToggleMenu:
|
||||
this.toggleMenu();
|
||||
break;
|
||||
case KeyBindTarget.OpenHelp:
|
||||
this.openShortcutModal();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[KeyBindTarget.ToggleFullScreen, KeyBindTarget.BookmarkPage, KeyBindTarget.OpenHelp, KeyBindTarget.GoTo,
|
||||
KeyBindTarget.ToggleMenu, KeyBindTarget.PageRight, KeyBindTarget.PageLeft, KeyBindTarget.Escape],
|
||||
);
|
||||
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
() => {
|
||||
this.toggleMenu();
|
||||
this.settingsOpen = !this.settingsOpen;
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
[KeyBindTarget.NavigateToSettings]
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -615,6 +670,33 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.readerService.disableWakeLock();
|
||||
}
|
||||
|
||||
private handlePageLeft() {
|
||||
switch (this.readerMode) {
|
||||
case ReaderMode.LeftRight:
|
||||
if (this.checkIfPaginationAllowed(KeyDirection.Left)) {
|
||||
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
|
||||
}
|
||||
break
|
||||
case ReaderMode.UpDown:
|
||||
if (this.checkIfPaginationAllowed(KeyDirection.Down)) {
|
||||
this.nextPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handlePageRight() {
|
||||
switch (this.readerMode) {
|
||||
case ReaderMode.LeftRight:
|
||||
if (this.checkIfPaginationAllowed(KeyDirection.Left)) {
|
||||
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
|
||||
}
|
||||
break
|
||||
case ReaderMode.UpDown:
|
||||
if (this.checkIfPaginationAllowed(KeyDirection.Down)) {
|
||||
this.prevPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
@HostListener('window:orientationchange', ['$event'])
|
||||
@ -622,54 +704,6 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.disableDoubleRendererIfScreenTooSmall();
|
||||
}
|
||||
|
||||
@HostListener('window:keyup', ['$event'])
|
||||
async handleKeyPress(event: KeyboardEvent) {
|
||||
switch (this.readerMode) {
|
||||
case ReaderMode.LeftRight:
|
||||
if (event.key === KEY_CODES.RIGHT_ARROW) {
|
||||
if (!this.checkIfPaginationAllowed(KeyDirection.Right)) return;
|
||||
this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
|
||||
} else if (event.key === KEY_CODES.LEFT_ARROW) {
|
||||
if (!this.checkIfPaginationAllowed(KeyDirection.Left)) return;
|
||||
this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
|
||||
}
|
||||
break;
|
||||
case ReaderMode.UpDown:
|
||||
if (event.key === KEY_CODES.UP_ARROW) {
|
||||
if (!this.checkIfPaginationAllowed(KeyDirection.Up)) return;
|
||||
this.prevPage();
|
||||
} else if (event.key === KEY_CODES.DOWN_ARROW) {
|
||||
if (!this.checkIfPaginationAllowed(KeyDirection.Down)) return;
|
||||
this.nextPage();
|
||||
}
|
||||
break;
|
||||
case ReaderMode.Webtoon:
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.key === KEY_CODES.ESC_KEY) {
|
||||
if (this.menuOpen) {
|
||||
this.toggleMenu();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.closeReader();
|
||||
} else if (event.key === KEY_CODES.SPACE) {
|
||||
this.toggleMenu();
|
||||
} else if (event.key === KEY_CODES.G) {
|
||||
const goToPageNum = await this.promptForPage();
|
||||
if (goToPageNum === null) { return; }
|
||||
this.goToPage(parseInt(goToPageNum.trim(), 10));
|
||||
} else if (event.key === KEY_CODES.B) {
|
||||
this.bookmarkPage();
|
||||
} else if (event.key === KEY_CODES.F) {
|
||||
this.toggleFullscreen();
|
||||
} else if (event.key === KEY_CODES.H) {
|
||||
this.openShortcutModal();
|
||||
}
|
||||
}
|
||||
|
||||
setupReaderSettings() {
|
||||
|
||||
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
|
||||
@ -1824,20 +1858,34 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
shortCutModalOpen = signal(false);
|
||||
shortCutModalRef: NgbModalRef | undefined;
|
||||
|
||||
private closeShortCutModal() {
|
||||
if (this.shortCutModalRef) {
|
||||
this.shortCutModalRef.dismiss();
|
||||
this.shortCutModalRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// This is menu only code
|
||||
openShortcutModal() {
|
||||
const ref = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' });
|
||||
ref.componentInstance.shortcuts = [
|
||||
{key: '⇽', description: 'prev-page'},
|
||||
{key: '⇾', description: 'next-page'},
|
||||
{key: '↑', description: 'prev-page'},
|
||||
{key: '↓', description: 'next-page'},
|
||||
{key: 'G', description: 'go-to'},
|
||||
{key: 'B', description: 'bookmark'},
|
||||
if (this.shortCutModalOpen()) return;
|
||||
|
||||
this.shortCutModalOpen.set(true);
|
||||
this.shortCutModalRef = this.modalService.open(ShortcutsModalComponent, { scrollable: true, size: 'md' });
|
||||
this.shortCutModalRef.componentInstance.shortcuts = [
|
||||
{keyBindTarget: KeyBindTarget.PageLeft, description: 'prev-page'},
|
||||
{keyBindTarget: KeyBindTarget.PageRight, description: 'next-page'},
|
||||
{keyBindTarget: KeyBindTarget.GoTo, description: 'go-to'},
|
||||
{keyBindTarget: KeyBindTarget.ToggleFullScreen},
|
||||
{keyBindTarget: KeyBindTarget.ToggleMenu},
|
||||
{keyBindTarget: KeyBindTarget.OpenHelp},
|
||||
{keyBindTarget: KeyBindTarget.BookmarkPage, description: 'bookmark'},
|
||||
{key: translate('shortcuts-modal.double-click'), description: 'bookmark'},
|
||||
{key: 'ESC', description: 'close-reader'},
|
||||
{key: 'SPACE', description: 'toggle-menu'},
|
||||
];
|
||||
|
||||
merge(this.shortCutModalRef.closed, this.shortCutModalRef.dismissed).subscribe(() => this.shortCutModalOpen.set(false));
|
||||
}
|
||||
|
||||
// menu only code
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
}
|
||||
} @else {
|
||||
<div class="input-hint d-none d-lg-block">
|
||||
Ctrl+K
|
||||
{{keyBindService.allKeyBinds().OpenSearch.at(0) | keyBind}}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -23,6 +23,9 @@ import {NgClass, NgTemplateOutlet} from '@angular/common';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {map, startWith, tap} from "rxjs";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {KeyBindEvent, KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
import {KeyBindPipe} from "../../../_pipes/key-bind.pipe";
|
||||
|
||||
export interface SearchEvent {
|
||||
value: string;
|
||||
@ -34,12 +37,13 @@ export interface SearchEvent {
|
||||
templateUrl: './grouped-typeahead.component.html',
|
||||
styleUrls: ['./grouped-typeahead.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective]
|
||||
imports: [ReactiveFormsModule, NgClass, NgTemplateOutlet, TranslocoDirective, KeyBindPipe]
|
||||
})
|
||||
export class GroupedTypeaheadComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly accountService = inject(AccountService);
|
||||
protected readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
/**
|
||||
* Unique id to tie with a label element
|
||||
@ -126,37 +130,36 @@ export class GroupedTypeaheadComponent implements OnInit {
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
|
||||
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
|
||||
|
||||
switch(event.key) {
|
||||
case KEY_CODES.ESC_KEY:
|
||||
if (!this.hasFocus) { return; }
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
|
||||
case KEY_CODES.K:
|
||||
if (isCtrlOrMeta) {
|
||||
if (this.inputElem.nativeElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.inputElem.nativeElement.focus();
|
||||
this.inputElem.nativeElement.click();
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private focusElement(e: KeyBindEvent) {
|
||||
if (this.inputElem.nativeElement) {
|
||||
e.triggered = true;
|
||||
this.inputElem.nativeElement.focus();
|
||||
this.inputElem.nativeElement.click();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
(e) => this.focusElement(e),
|
||||
[KeyBindTarget.OpenSearch],
|
||||
{fireInEditable: true},
|
||||
);
|
||||
|
||||
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
|
||||
startWith(false),
|
||||
map(val => {
|
||||
|
||||
@ -7,11 +7,18 @@ import {
|
||||
HostListener,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit, signal,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {ActivatedRoute, Router} from '@angular/router';
|
||||
import {NgxExtendedPdfViewerModule, pdfDefaultOptions, PageViewModeType, ProgressBarEvent, ScrollModeType} from 'ngx-extended-pdf-viewer';
|
||||
import {
|
||||
NgxExtendedPdfViewerModule,
|
||||
PageViewModeType,
|
||||
pdfDefaultOptions,
|
||||
ProgressBarEvent,
|
||||
ScrollModeType
|
||||
} from 'ngx-extended-pdf-viewer';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {take} from 'rxjs';
|
||||
import {BookService} from 'src/app/book-reader/_services/book.service';
|
||||
@ -36,6 +43,8 @@ import {PdfSpreadTypePipe} from "../../_pipe/pdf-spread-mode.pipe";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
|
||||
@Component({
|
||||
selector: 'app-pdf-reader',
|
||||
@ -61,6 +70,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
public readonly document = inject(DOCUMENT);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
protected readonly ScrollModeType = ScrollModeType;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
@ -130,13 +140,12 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
this.navService.hideNavBar();
|
||||
this.themeService.clearThemes();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
|
||||
@HostListener('window:keyup', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
if (event.key === KEY_CODES.ESC_KEY) {
|
||||
this.closeReader();
|
||||
}
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
() => this.closeReader(),
|
||||
[KeyBindTarget.Escape],
|
||||
);
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
<div class="row g-0">
|
||||
<div class="mb-3 col-md-6 col-xs-12 pe-2">
|
||||
@if (editForm.get('malId'); as formControl) {
|
||||
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('mal-tooltip')">
|
||||
<app-setting-item [title]="t('mal-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('mal-tooltip'+tooltip)">
|
||||
<ng-template #view>
|
||||
<input id="mal-id" class="form-control" formControlName="malId" type="number"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
@ -45,7 +45,7 @@
|
||||
|
||||
<div class="mb-3 col-md-6 col-xs-12">
|
||||
@if (editForm.get('aniListId'); as formControl) {
|
||||
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('anilist-tooltip')">
|
||||
<app-setting-item [title]="t('anilist-id-label')" [toggleOnViewClick]="false" [showEdit]="false" [subtitle]="t('anilist-tooltip'+tooltip)">
|
||||
<ng-template #view>
|
||||
<input id="anilist-id" class="form-control" formControlName="aniListId" type="number"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
ValidationErrors,
|
||||
Validators
|
||||
} from "@angular/forms";
|
||||
import {Person} from "../../../_models/metadata/person";
|
||||
import {Person, PersonRole} from "../../../_models/metadata/person";
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbNav,
|
||||
@ -22,7 +22,7 @@ import {
|
||||
import {PersonService} from "../../../_services/person.service";
|
||||
import {translate, TranslocoDirective} from '@jsverse/transloco';
|
||||
import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component";
|
||||
import {concat, forkJoin, map, of} from "rxjs";
|
||||
import {concat, map, of} from "rxjs";
|
||||
import {UploadService} from "../../../_services/upload.service";
|
||||
import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
@ -84,6 +84,11 @@ export class EditPersonModalComponent implements OnInit {
|
||||
coverImageReset = false;
|
||||
touchedCoverImage = false;
|
||||
fetchDisabled: boolean = false;
|
||||
/**
|
||||
* Suffix to include in the tooltip for external ids if they support characters
|
||||
*/
|
||||
tooltip: string = '';
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
if (this.person) {
|
||||
@ -97,6 +102,11 @@ export class EditPersonModalComponent implements OnInit {
|
||||
this.editForm.addControl('coverImageIndex', new FormControl(0, []));
|
||||
this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, []));
|
||||
|
||||
const roles = (this.person.roles ?? []);
|
||||
if (roles.length === 1 && roles.includes(PersonRole.Character)) {
|
||||
this.tooltip = '-character';
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
} else {
|
||||
alert('no person')
|
||||
|
||||
@ -1,18 +1,42 @@
|
||||
<ng-container *transloco="let t; prefix: 'shortcuts-modal'">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<h4 class="modal-title" id="modal-basic-title">{{ t('title') }}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="modal.close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-0">
|
||||
@for(shortcut of shortcuts; track shortcut.key) {
|
||||
<div class="col-md-6 mb-2">
|
||||
<span><code>{{shortcut.key}}</code> {{t(shortcut.description)}}</span>
|
||||
</div>
|
||||
@for(shortcut of shortcuts; track shortcut.keyBindTarget ?? shortcut.key ?? $index) {
|
||||
@if (shortcut.keyBindTarget) {
|
||||
<!-- Show at most 2 of the keybinds to not make the modal too long -->
|
||||
@for(keyBind of keyBindService.allKeyBinds()[shortcut.keyBindTarget].slice(0, 2); track $index) {
|
||||
<div class="col-md-6 mb-2">
|
||||
<span>
|
||||
<code>{{ keyBind | keyBind }}</code>
|
||||
@if (shortcut.description) {
|
||||
{{ t(shortcut.description) }}
|
||||
} @else {
|
||||
@let settingDesc = shortcut.keyBindTarget | keybindSettingDescription;
|
||||
<span>
|
||||
{{ settingDesc.tooltip }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="col-md-6 mb-2">
|
||||
<span>
|
||||
<code>{{ shortcut.key }}</code>
|
||||
@if (shortcut.description) {
|
||||
{{ t(shortcut.description) }}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" (click)="modal.close()">{{t('close')}}</button>
|
||||
<button type="button" class="btn btn-primary" (click)="modal.close()">{{ t('close') }}</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@ -1,27 +1,36 @@
|
||||
import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core';
|
||||
import {NgbActiveModal, NgbModalModule} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindPipe} from "../../../_pipes/key-bind.pipe";
|
||||
import {KeybindSettingDescriptionPipe} from "../../../_pipes/keybind-setting-description.pipe";
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
/**
|
||||
* String representing key or key combo. Should use + for combos. Will render as upper case
|
||||
*/
|
||||
key: string;
|
||||
key?: string;
|
||||
/**
|
||||
* Description of how it works
|
||||
*/
|
||||
description: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Keybind target, will display the first configured keybind instead of the given key
|
||||
*/
|
||||
keyBindTarget?: KeyBindTarget;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-shortcuts-modal',
|
||||
imports: [NgbModalModule, TranslocoDirective],
|
||||
templateUrl: './shortcuts-modal.component.html',
|
||||
styleUrls: ['./shortcuts-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-shortcuts-modal',
|
||||
imports: [NgbModalModule, TranslocoDirective, KeyBindPipe, KeybindSettingDescriptionPipe],
|
||||
templateUrl: './shortcuts-modal.component.html',
|
||||
styleUrls: ['./shortcuts-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ShortcutsModalComponent {
|
||||
|
||||
protected readonly keyBindService = inject(KeyBindService);
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input() shortcuts: Array<KeyboardShortcut> = [];
|
||||
|
||||
@ -133,7 +133,10 @@
|
||||
@if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) {
|
||||
<a class="dark-exempt btn-icon font-size" (click)="openFilter(FilterField.PublicationStatus, seriesMetadata!.publicationStatus)"
|
||||
href="javascript:void(0);"
|
||||
[ngbTooltip]="t('publication-status-tooltip') + (seriesMetadata.totalCount === 0 ? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')">
|
||||
[ngbTooltip]="t('publication-status-tooltip') +
|
||||
((seriesMetadata.totalCount === 0 || seriesMetadata.maxCount === LooseLeafOrSpecialNumber)
|
||||
? '' : ' (' + seriesMetadata.maxCount + ' / ' + seriesMetadata.totalCount + ')')"
|
||||
>
|
||||
{{pubStatus}}
|
||||
</a>
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
|
||||
|
||||
<app-tag-badge *transloco="let t; prefix: 'manage-custom-key-binds'" [selectionMode]="tagBadgeCursor()" [ngbTooltip]="t('key-bind-tooltip')">
|
||||
<div id="key-bind-{{target()}}-{{index()}}" tabindex="-1" (click)="startListening()" (focus)="startListening()" class="d-flex align-items-center">
|
||||
|
||||
@let keybind = selectedKeyBind() | keyBind;
|
||||
@if (!this.isListening()) {
|
||||
{{keybind | defaultValue}}
|
||||
} @else {
|
||||
{{keybind}}
|
||||
}
|
||||
|
||||
@if (this.isListening()) {
|
||||
<span class="typing-cursor"></span>
|
||||
}
|
||||
|
||||
@if (duplicated()) {
|
||||
<i class="fa fa-warning text-warning ms-2"
|
||||
[attr.aria-label]="t('warning-duplicated-control', {target: target(), index: index() + 1})"></i>
|
||||
}
|
||||
|
||||
@if (!control().valid) {
|
||||
<i class="fa fa-warning text-danger ms-2"
|
||||
[attr.aria-label]="t('errors-control', {target: target(), index: index() + 1})"></i>
|
||||
}
|
||||
|
||||
</div>
|
||||
</app-tag-badge>
|
||||
@ -0,0 +1,18 @@
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 1em;
|
||||
background-color: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%, 100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
forwardRef,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR} from "@angular/forms";
|
||||
import {KeyBindService, KeyCode, ModifierKeyCodes} from "../../../_services/key-bind.service";
|
||||
import {KeyBind, KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
import {KeyBindPipe} from "../../../_pipes/key-bind.pipe";
|
||||
import {DOCUMENT} from "@angular/common";
|
||||
import {GamePadService} from "../../../_services/game-pad.service";
|
||||
import {filter, fromEvent, merge, Subscription, tap} from "rxjs";
|
||||
import {TagBadgeComponent, TagBadgeCursor} from "../../../shared/tag-badge/tag-badge.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DefaultValuePipe} from "../../../_pipes/default-value.pipe";
|
||||
import {AccountService} from "../../../_services/account.service";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {debounceTime, take} from "rxjs/operators";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
|
||||
@Component({
|
||||
selector: 'app-setting-key-bind-picker',
|
||||
imports: [
|
||||
KeyBindPipe,
|
||||
TagBadgeComponent,
|
||||
TranslocoDirective,
|
||||
DefaultValuePipe,
|
||||
NgbTooltip
|
||||
],
|
||||
templateUrl: './setting-key-bind-picker.component.html',
|
||||
styleUrl: './setting-key-bind-picker.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SettingKeyBindPickerComponent),
|
||||
multi: true,
|
||||
}
|
||||
]
|
||||
})
|
||||
export class SettingKeyBindPickerComponent implements ControlValueAccessor, OnDestroy {
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly keyBindService = inject(KeyBindService);
|
||||
private readonly gamePadService = inject(GamePadService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly elementRef = inject(ElementRef);
|
||||
|
||||
control = input.required<FormControl<KeyBind>>();
|
||||
target = input.required<KeyBindTarget>();
|
||||
index = input.required<number>();
|
||||
duplicated = input.required<boolean>();
|
||||
|
||||
selectedKeyBind = signal<KeyBind>({key: KeyCode.Empty});
|
||||
disabled = signal(false);
|
||||
|
||||
private _onChange: (value: KeyBind) => void = () => {};
|
||||
private _onTouched: () => void = () => {};
|
||||
protected readonly subscriptions = signal<Subscription[]>([]);
|
||||
protected readonly isListening = computed(() => this.subscriptions().length > 0);
|
||||
protected readonly tagBadgeCursor = computed(() =>
|
||||
this.accountService.isReadOnly() ? TagBadgeCursor.NotAllowed : TagBadgeCursor.Clickable);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const selectedKeys = this.selectedKeyBind();
|
||||
this._onChange(selectedKeys);
|
||||
this._onTouched();
|
||||
});
|
||||
|
||||
fromEvent(this.document, 'click')
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
filter((event: Event) => {
|
||||
return !this.elementRef.nativeElement.contains(event.target);
|
||||
}),
|
||||
filter(() => this.isListening()),
|
||||
tap(() => this.stopListening()),
|
||||
).subscribe();
|
||||
|
||||
}
|
||||
|
||||
writeValue(keyBind: KeyBind): void {
|
||||
this.selectedKeyBind.set(keyBind)
|
||||
}
|
||||
|
||||
registerOnChange(fn: (_: KeyBind) => void): void {
|
||||
this._onChange = fn;
|
||||
}
|
||||
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this._onTouched = fn;
|
||||
}
|
||||
|
||||
setDisabledState?(isDisabled: boolean): void {
|
||||
this.disabled.set(isDisabled);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.keyBindService.disabled.set(false);
|
||||
this.subscriptions().forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
startListening() {
|
||||
if (this.isListening() || this.accountService.isReadOnly()) return;
|
||||
|
||||
this.keyBindService.disabled.set(true);
|
||||
this.document.addEventListener('keydown', this.onKeyDown);
|
||||
|
||||
const keydown$ = fromEvent(this.document, 'keydown').pipe(
|
||||
tap((e) => this.onKeyDown(e as KeyboardEvent)),
|
||||
);
|
||||
|
||||
const gamePad$ = this.gamePadService.keyDownEvents$.pipe(
|
||||
tap(e => this.selectedKeyBind.set({
|
||||
key: KeyCode.Empty,
|
||||
controllerSequence: e.pressedButtons,
|
||||
})),
|
||||
);
|
||||
|
||||
const sub = merge(keydown$, gamePad$).pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
debounceTime(700),
|
||||
filter(() => this.control().valid),
|
||||
take(1),
|
||||
tap(() => this.stopListening()),
|
||||
).subscribe();
|
||||
|
||||
this.subscriptions.update(s => [sub, ...s]);
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
this.keyBindService.disabled.set(false);
|
||||
this.subscriptions().forEach(s => s.unsubscribe());
|
||||
this.subscriptions.set([]);
|
||||
}
|
||||
|
||||
private onKeyDown = (event: KeyboardEvent) => {
|
||||
const eventKey = event.key.toLowerCase() as KeyCode;
|
||||
|
||||
this.selectedKeyBind.set({
|
||||
key: ModifierKeyCodes.includes(eventKey) ? KeyCode.Empty : eventKey,
|
||||
meta: event.metaKey,
|
||||
alt: event.altKey,
|
||||
control: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
}
|
||||
@ -177,6 +177,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.CustomKeyBinds; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.CustomKeyBinds) {
|
||||
<div class="col-xxl-6 col-12">
|
||||
<app-manage-custom-key-binds />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.Customize; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.Customize) {
|
||||
<div class="scale col-md-12">
|
||||
|
||||
@ -61,6 +61,7 @@ import {
|
||||
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
|
||||
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
|
||||
import {FontManagerComponent} from "../../../user-settings/font-manager/font-manager/font-manager.component";
|
||||
import {ManageCustomKeyBindsComponent} from "../../../user-settings/custom-key-binds/manage-custom-key-binds.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@ -101,7 +102,8 @@ import {FontManagerComponent} from "../../../user-settings/font-manager/font-man
|
||||
ManageOpenIDConnectComponent,
|
||||
ManagePublicMetadataSettingsComponent,
|
||||
ImportMappingsComponent,
|
||||
FontManagerComponent
|
||||
FontManagerComponent,
|
||||
ManageCustomKeyBindsComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
|
||||
@ -26,7 +26,10 @@ export enum KEY_CODES {
|
||||
K = 'k',
|
||||
BACKSPACE = 'Backspace',
|
||||
DELETE = 'Delete',
|
||||
SHIFT = 'Shift'
|
||||
SHIFT = 'Shift',
|
||||
CONTROL = 'Control',
|
||||
META = 'Meta',
|
||||
ALT = 'Alt',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, effect, inject, OnInit} from '@angular/core';
|
||||
import {NavigationEnd, Router} from '@angular/router';
|
||||
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {distinctUntilChanged, filter, map, take, tap} from 'rxjs/operators';
|
||||
@ -26,6 +26,8 @@ import {LicenseService} from "../../../_services/license.service";
|
||||
import {CdkDrag, CdkDragDrop, CdkDropList} from "@angular/cdk/drag-drop";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {KeyBindService} from "../../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../../_models/preferences/preferences";
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
@ -57,6 +59,7 @@ export class SideNavComponent implements OnInit {
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly readingProfilesService = inject(ReadingProfileService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
|
||||
cachedData: SideNavStream[] | null = null;
|
||||
@ -146,6 +149,21 @@ export class SideNavComponent implements OnInit {
|
||||
this.navService.collapseSideNav(false);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
(e) => this.router.navigate(['/settings'], { fragment: SettingsTabId.Account}),
|
||||
[KeyBindTarget.NavigateToSettings],
|
||||
{condition$: this.navService.sideNavVisibility$},
|
||||
);
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
(e) => this.router.navigate(['/settings'], { fragment: SettingsTabId.Scrobbling}),
|
||||
[KeyBindTarget.NavigateToScrobbling],
|
||||
{condition$: this.licenseService.hasValidLicense$},
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@ -257,7 +275,7 @@ export class SideNavComponent implements OnInit {
|
||||
|
||||
const stream = $event.item.data;
|
||||
// Offset the home, back, and customize button
|
||||
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3).subscribe({
|
||||
this.navService.updateSideNavStreamPosition(stream.name, stream.id, stream.order, $event.currentIndex - 3, false).subscribe({
|
||||
next: () => {
|
||||
this.showAllSubject.next(this.showAll);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
@ -74,7 +74,7 @@
|
||||
<a ngbNavLink>{{t(TabID.Folder)}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
||||
@if (filesAtRoot()) {
|
||||
@if (filesAtRoot().length > 0) {
|
||||
<p class="alert alert-warning">{{t('files-at-root-warning')}}</p>
|
||||
}
|
||||
|
||||
@ -83,7 +83,15 @@
|
||||
@for(folder of selectedFolders; track folder) {
|
||||
<li class="list-group-item">
|
||||
{{folder}}
|
||||
<button class="btn float-end btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
|
||||
|
||||
<div class="float-end d-flex flex-row align-items-center">
|
||||
@if (filesAtRoot().includes(folder)) {
|
||||
<i class="fa fa-warning text-danger" [ngbTooltip]="t('files-at-root-warning')"></i>
|
||||
}
|
||||
|
||||
<button class="btn btn-sm" (click)="removeFolder(folder)"><i class="fa fa-times-circle" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@ -236,6 +244,23 @@
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('default-language-label')" [subtitle]="t('default-language-tooltip')" [toggleOnViewClick]="false" [showEdit]="false">
|
||||
<ng-template #view>
|
||||
@if (languageSettings) {
|
||||
<app-typeahead (selectedData)="updateLanguage($event)" [settings]="languageSettings" [locked]="false">
|
||||
<ng-template #badgeItem let-item let-position="idx">
|
||||
{{item.title}}
|
||||
</ng-template>
|
||||
<ng-template #optionItem let-item let-position="idx">
|
||||
{{item.title}} ({{item.isoCode}})
|
||||
</ng-template>
|
||||
</app-typeahead>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<div class="row g-0 mt-4 pb-4">
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {debounceTime, distinctUntilChanged, switchMap, tap} from 'rxjs';
|
||||
import {debounceTime, distinctUntilChanged, of, switchMap, tap} from 'rxjs';
|
||||
import {
|
||||
DirectoryPickerComponent,
|
||||
DirectoryPickerResult
|
||||
@ -54,6 +54,11 @@ import {Action, ActionFactoryService, ActionItem} from "../../../_services/actio
|
||||
import {ActionService} from "../../../_services/action.service";
|
||||
import {LibraryTypePipe} from "../../../_pipes/library-type.pipe";
|
||||
import {LibraryTypeSubtitlePipe} from "../../../_pipes/library-type-subtitle.pipe";
|
||||
import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component";
|
||||
import {setupLanguageSettings, TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings";
|
||||
import {Language} from "../../../_models/metadata/language";
|
||||
import {map} from "rxjs/operators";
|
||||
import {MetadataService} from "../../../_services/metadata.service";
|
||||
|
||||
enum TabID {
|
||||
General = 'general-tab',
|
||||
@ -74,7 +79,7 @@ enum StepID {
|
||||
selector: 'app-library-settings-modal',
|
||||
imports: [NgbModalModule, NgbNavLink, NgbNavItem, NgbNavContent, ReactiveFormsModule, NgbTooltip,
|
||||
SentenceCasePipe, NgbNav, NgbNavOutlet, CoverImageChooserComponent, TranslocoModule, DefaultDatePipe,
|
||||
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe],
|
||||
FileTypeGroupPipe, EditListComponent, SettingItemComponent, SettingSwitchComponent, SettingButtonComponent, LibraryTypeSubtitlePipe, NgTemplateOutlet, DatePipe, TypeaheadComponent],
|
||||
templateUrl: './library-settings-modal.component.html',
|
||||
styleUrls: ['./library-settings-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@ -93,6 +98,7 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
private readonly imageService = inject(ImageService);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
@ -124,6 +130,7 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
enableMetadata: new FormControl<boolean>(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true
|
||||
removePrefixForSortName: new FormControl<boolean>(false, { nonNullable: true, validators: [] }),
|
||||
inheritWebLinksFromFirstChapter: new FormControl<boolean>(false, { nonNullable: true, validators: []}),
|
||||
defaultLanguage: new FormControl<string>('', {nonNullable: true, validators: []}),
|
||||
// TODO: Missing excludePatterns
|
||||
});
|
||||
|
||||
@ -133,11 +140,13 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
return {title: this.libraryTypePipe.transform(f), value: f};
|
||||
}).sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
languageSettings: TypeaheadSettings<Language> | null = null;
|
||||
|
||||
isAddLibrary = false;
|
||||
setupStep = StepID.General;
|
||||
fileTypeGroups = allFileTypeGroup;
|
||||
excludePatterns: Array<string> = [''];
|
||||
filesAtRoot = model<boolean>(false);
|
||||
filesAtRoot = model<Array<string>>([]);
|
||||
|
||||
tasks: ActionItem<Library>[] = this.getTasks();
|
||||
|
||||
@ -159,8 +168,6 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
if (this.library === undefined) {
|
||||
this.isAddLibrary = true;
|
||||
this.cdRef.markForCheck();
|
||||
} else {
|
||||
this.checkForFilesAtRoot();
|
||||
}
|
||||
|
||||
if (this.library?.coverImage != null && this.library?.coverImage !== '') {
|
||||
@ -201,6 +208,7 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
|
||||
|
||||
this.setValues();
|
||||
this.setupLanguageTypeahead().subscribe();
|
||||
|
||||
// Turn on/off manage collections/rl
|
||||
this.libraryForm.get('enableMetadata')?.valueChanges.pipe(
|
||||
@ -293,7 +301,9 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata);
|
||||
this.libraryForm.get('removePrefixForSortName')?.setValue(this.library.removePrefixForSortName);
|
||||
this.libraryForm.get('inheritWebLinksFromFirstChapter')?.setValue(this.library.inheritWebLinksFromFirstChapter);
|
||||
this.libraryForm.get('defaultLanguage')?.setValue(this.library.defaultLanguage);
|
||||
this.selectedFolders = this.library.folders;
|
||||
this.checkForFilesAtRoot(); // check after selectedFolders has been set
|
||||
|
||||
this.madeChanges = false;
|
||||
|
||||
@ -321,6 +331,21 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
setupLanguageTypeahead() {
|
||||
return this.metadataService.getAllValidLanguages()
|
||||
.pipe(
|
||||
tap(validLanguages => {
|
||||
this.languageSettings = setupLanguageSettings(false, this.utilityService, validLanguages, this.library?.defaultLanguage)
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
switchMap(_ => of(true))
|
||||
);
|
||||
}
|
||||
|
||||
updateLanguage(languages: Array<Language>) {
|
||||
this.libraryForm.get("defaultLanguage")!.setValue(languages.at(0)?.isoCode ?? '');
|
||||
}
|
||||
|
||||
updateGlobs(items: Array<string>) {
|
||||
this.excludePatterns = items;
|
||||
this.cdRef.markForCheck();
|
||||
@ -427,7 +452,7 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
if (!this.selectedFolders.includes(closeResult.folderPath)) {
|
||||
this.selectedFolders.push(closeResult.folderPath);
|
||||
this.madeChanges = true;
|
||||
this.checkForFilesAtRoot();
|
||||
this.checkForFilesAtRoot(true);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
@ -478,17 +503,14 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
checkForFilesAtRoot() {
|
||||
checkForFilesAtRoot(showToast: boolean = false) {
|
||||
this.libraryService.hasFilesAtRoot(this.selectedFolders).subscribe(results => {
|
||||
let containsMultipleFiles = false;
|
||||
Object.keys(results).forEach(key => {
|
||||
if (results[key]) {
|
||||
containsMultipleFiles = true;
|
||||
return;
|
||||
}
|
||||
});
|
||||
const newValues = results.filter(item => !this.filesAtRoot().includes(item));
|
||||
if (showToast && newValues.length > 0) {
|
||||
this.toastr.error(translate('library-settings-modal.files-at-root-warning'))
|
||||
}
|
||||
|
||||
this.filesAtRoot.set(containsMultipleFiles);
|
||||
this.filesAtRoot.set(results);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ import {Breakpoint, UtilityService} from "../../shared/_services/utility.service
|
||||
import {LicenseService} from "../../_services/license.service";
|
||||
import {ManageService} from "../../_services/manage.service";
|
||||
import {MatchStateOption} from "../../_models/kavitaplus/match-state-option";
|
||||
import {KeyBindService} from "../../_services/key-bind.service";
|
||||
import {KeyBindTarget} from "../../_models/preferences/preferences";
|
||||
|
||||
export enum SettingsTabId {
|
||||
|
||||
@ -52,6 +54,7 @@ export enum SettingsTabId {
|
||||
// Non-Admin
|
||||
Account = 'account',
|
||||
Preferences = 'preferences',
|
||||
CustomKeyBinds = 'custom-key-binds',
|
||||
ReadingProfiles = 'reading-profiles',
|
||||
Font = 'font',
|
||||
Clients = 'clients',
|
||||
@ -135,6 +138,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
private readonly manageService = inject(ManageService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly keyBindService = inject(KeyBindService);
|
||||
|
||||
/**
|
||||
* This links to settings.component.html which has triggers on what underlying component to render out.
|
||||
@ -219,8 +223,9 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
{
|
||||
title: SettingSectionId.AccountSection,
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.Account, []),
|
||||
new SideNavItem(SettingsTabId.Account),
|
||||
new SideNavItem(SettingsTabId.Preferences),
|
||||
new SideNavItem(SettingsTabId.CustomKeyBinds),
|
||||
new SideNavItem(SettingsTabId.ReadingProfiles),
|
||||
new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]),
|
||||
new SideNavItem(SettingsTabId.Clients),
|
||||
@ -281,6 +286,14 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
this.licenseService.hasValidLicenseSignal();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.keyBindService.registerListener(
|
||||
this.destroyRef,
|
||||
() => this.router.navigate(['/settings'], { fragment: SettingsTabId.Scrobbling})
|
||||
.then(() => this.scrollToActiveItem()),
|
||||
[KeyBindTarget.NavigateToScrobbling],
|
||||
{condition$: this.licenseService.hasValidLicense$},
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
|
||||
@ -172,21 +172,22 @@ export class TypeaheadComponent implements OnInit {
|
||||
);
|
||||
|
||||
|
||||
if (this.settings.savedData) {
|
||||
if (this.settings.multiple) {
|
||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
||||
}
|
||||
else {
|
||||
const isArray = this.settings.savedData.hasOwnProperty('length');
|
||||
if (isArray) {
|
||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
||||
} else {
|
||||
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!this.settings.savedData) {
|
||||
this.optionSelection = new SelectionModel<any>();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.settings.multiple) {
|
||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(this.settings.savedData)) {
|
||||
this.optionSelection = new SelectionModel<any>(true, this.settings.savedData);
|
||||
return;
|
||||
}
|
||||
|
||||
this.optionSelection = new SelectionModel<any>(true, [this.settings.savedData]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import {Observable} from 'rxjs';
|
||||
import {Observable, of} from 'rxjs';
|
||||
import {FormControl} from '@angular/forms';
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {map} from "rxjs/operators";
|
||||
import {UtilityService} from "../../shared/_services/utility.service";
|
||||
|
||||
export type SelectionCompareFn<T> = (a: T, b: T) => boolean;
|
||||
|
||||
@ -70,3 +73,48 @@ export class TypeaheadSettings<T> {
|
||||
*/
|
||||
trackByIdentityFn!: (index: number, value: T) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a new TypeaheadSettings<Language> as a language type ahead
|
||||
* @param showLocked
|
||||
* @param utilityService
|
||||
* @param allLanguages
|
||||
* @param currentSelectedLanguage
|
||||
* @returns settings
|
||||
*/
|
||||
export function setupLanguageSettings(
|
||||
showLocked: boolean,
|
||||
utilityService: UtilityService,
|
||||
allLanguages: Array<Language>,
|
||||
currentSelectedLanguage: string | Array<string> | undefined,
|
||||
): TypeaheadSettings<Language> {
|
||||
const settings = new TypeaheadSettings<Language>();
|
||||
|
||||
settings.minCharacters = 0;
|
||||
settings.multiple = false;
|
||||
settings.id = 'language';
|
||||
settings.unique = true;
|
||||
settings.showLocked = showLocked;
|
||||
settings.addIfNonExisting = false;
|
||||
settings.compareFn = (options: Language[], filter: string) => {
|
||||
return options.filter(m => utilityService.filter(m.title, filter));
|
||||
}
|
||||
settings.compareFnForAdd = (options: Language[], filter: string) => {
|
||||
return options.filter(m => utilityService.filterMatches(m.title, filter));
|
||||
}
|
||||
settings.fetchFn = (filter: string) => of(allLanguages)
|
||||
.pipe(map(items => settings.compareFn(items, filter)));
|
||||
|
||||
settings.selectionCompareFn = (a: Language, b: Language) => {
|
||||
return a.isoCode === b.isoCode;
|
||||
}
|
||||
|
||||
settings.trackByIdentityFn = (_, value) => value.isoCode;
|
||||
|
||||
const l = allLanguages.find(l => l.isoCode === currentSelectedLanguage);
|
||||
if (l !== undefined) {
|
||||
settings.savedData = l;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
<ng-container *transloco="let t; prefix: 'manage-custom-key-binds'">
|
||||
<p [innerHTML]="t('description', {max: MAX_KEYBINDS_PER_TARGET}) | safeHtml">
|
||||
|
||||
</p>
|
||||
|
||||
<form [formGroup]="keyBindForm" class="row mb-4">
|
||||
|
||||
@for (keyBindGroup of filteredKeyBindGroups(); track keyBindGroup.title) {
|
||||
@if (!$first) {
|
||||
<div class="setting-section-break"></div>
|
||||
}
|
||||
|
||||
<h4>{{t(keyBindGroup.title)}}</h4>
|
||||
|
||||
@for (element of keyBindGroup.elements; track element.target) {
|
||||
@if (getFormArray(element.target); as array) {
|
||||
<div class="col-12 mt-4" [formArrayName]="element.target">
|
||||
@let settingDesc = element.target | keybindSettingDescription;
|
||||
<app-setting-item
|
||||
[title]="settingDesc.title"
|
||||
[subtitle]="settingDesc.tooltip"
|
||||
[labelId]="element.target + ''"
|
||||
[showEdit]="false"
|
||||
[canEdit]="false"
|
||||
[allowClickEvents]="true"
|
||||
>
|
||||
<ng-template #view>
|
||||
<div class="d-flex flex-row justify-content-between">
|
||||
<div>
|
||||
@for (keyBindControl of array.controls; track $index;) {
|
||||
<app-setting-key-bind-picker
|
||||
appLongClick
|
||||
[control]="keyBindControl"
|
||||
[formControlName]="$index + ''"
|
||||
[target]="element.target"
|
||||
[index]="$index"
|
||||
[duplicated]="duplicatedKeyBinds()[element.target]?.includes($index) ?? false"
|
||||
[ngbTooltip]="errorToolTip(element.target, $index, keyBindControl.errors)"
|
||||
(longClick)="removeKeyBind(element.target, $index)"
|
||||
/>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="float-end">
|
||||
@if (array.controls.length < MAX_KEYBINDS_PER_TARGET) {
|
||||
<button class="btn btn-sm" (click)="addKeyBind(element.target)" [ngbTooltip]="t('add', {target: settingDesc.title})">
|
||||
<i class="fa fa-add" [attr.aria-label]="t('add', {target: element.target})"></i>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button class="btn btn-sm" (click)="resetKeybindsToDefaults(element.target)" [ngbTooltip]="t('reset', {target: settingDesc.title})">
|
||||
<i class="fa fa-recycle" [attr.aria-label]="t('reset', {target: element.target})"></i>
|
||||
</button>
|
||||
|
||||
@if (!array.valid) {
|
||||
<i class="fa fa-warning text-danger"
|
||||
[attr.aria-label]="t('errors-array', {target: element.target})"
|
||||
[ngbTooltip]="errorToolTip(element.target, -1, array.errors)">
|
||||
|
||||
</i>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</form>
|
||||
|
||||
</ng-container>
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,277 @@
|
||||
import {ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal} from '@angular/core';
|
||||
import {DefaultKeyBinds, KeyBindGroups, KeyBindService, KeyCode,} from "../../_services/key-bind.service";
|
||||
import {
|
||||
FormArray,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn
|
||||
} from "@angular/forms";
|
||||
import {KeyBind, KeyBindTarget, Preferences} from "../../_models/preferences/preferences";
|
||||
import {TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {
|
||||
SettingKeyBindPickerComponent
|
||||
} from "../../settings/_components/setting-key-bind-picker/setting-key-bind-picker.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {catchError, debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs";
|
||||
import {map} from "rxjs/operators";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {LongClickDirective} from "../../_directives/long-click.directive";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {LicenseService} from "../../_services/license.service";
|
||||
import {KeybindSettingDescriptionPipe} from "../../_pipes/keybind-setting-description.pipe";
|
||||
import {DOCUMENT} from "@angular/common";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
|
||||
type KeyBindFormGroup = FormGroup<{
|
||||
[K in KeyBindTarget]: FormArray<FormControl<KeyBind>>
|
||||
}>;
|
||||
|
||||
const MAX_KEYBINDS_PER_TARGET = 5;
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-custom-key-binds',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
SettingItemComponent,
|
||||
SettingKeyBindPickerComponent,
|
||||
DefaultValuePipe,
|
||||
NgbTooltip,
|
||||
KeybindSettingDescriptionPipe,
|
||||
TranslocoDirective,
|
||||
LongClickDirective,
|
||||
SafeHtmlPipe
|
||||
],
|
||||
templateUrl: './manage-custom-key-binds.component.html',
|
||||
styleUrl: './manage-custom-key-binds.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ManageCustomKeyBindsComponent implements OnInit {
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
protected readonly keyBindService = inject(KeyBindService);
|
||||
private readonly transLoco = inject(TranslocoService);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly licenseService = inject(LicenseService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
protected keyBindForm!: KeyBindFormGroup;
|
||||
|
||||
protected duplicatedKeyBinds = signal<Partial<Record<KeyBindTarget, number[]>>>({});
|
||||
protected filteredKeyBindGroups = computed(() => {
|
||||
const roles = this.accountService.currentUserSignal()!.roles;
|
||||
const hasKPlus = this.licenseService.hasValidLicenseSignal();
|
||||
|
||||
return KeyBindGroups.map(g => {
|
||||
g.elements = g.elements.filter(e => {
|
||||
if (e.roles && !e.roles.some(r => roles.includes(r))) return false;
|
||||
if (e.restrictedRoles && e.restrictedRoles.some(r => roles.includes(r))) return false;
|
||||
|
||||
return hasKPlus || !e.kavitaPlus;
|
||||
})
|
||||
return g;
|
||||
}).filter(g => g.elements.length > 0);
|
||||
});
|
||||
|
||||
ngOnInit() {
|
||||
const keyBinds = this.keyBindService.allKeyBinds();
|
||||
const groupConfig = Object.entries(keyBinds).reduce((acc, [key, value]) => {
|
||||
acc[key as KeyBindTarget] = this.fb.array(this.toFormControls(value), this.keyBindArrayValidator());
|
||||
return acc;
|
||||
}, {} as Record<KeyBindTarget, FormArray<FormControl<KeyBind>>>);
|
||||
|
||||
this.keyBindForm = this.fb.group(groupConfig);
|
||||
this.duplicatedKeyBinds.set(this.extractDuplicated(keyBinds)); // Set initial
|
||||
|
||||
this.keyBindForm.valueChanges.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
debounceTime(250),
|
||||
distinctUntilChanged(),
|
||||
map(formValue => this.extractDuplicated(formValue)),
|
||||
tap(d => this.duplicatedKeyBinds.set(d)),
|
||||
).subscribe();
|
||||
|
||||
this.keyBindForm.valueChanges.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
debounceTime(500),
|
||||
distinctUntilChanged(),
|
||||
filter(() => this.keyBindForm.valid),
|
||||
map(formValue => this.extractCustomKeyBinds(formValue)),
|
||||
map(customKeyBinds => this.combinePreferences(customKeyBinds)),
|
||||
switchMap(p => this.accountService.updatePreferences(p)),
|
||||
catchError(err => {
|
||||
console.error(err);
|
||||
this.toastr.error(err);
|
||||
|
||||
return of(null);
|
||||
}),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
private extractDuplicated(formValue: Partial<Record<KeyBindTarget, KeyBind[]>>): Partial<Record<KeyBindTarget, number[]>> {
|
||||
const entries = Object.entries(formValue);
|
||||
|
||||
return Object.fromEntries(entries
|
||||
.map(([target, keyBinds]) => {
|
||||
const duplicatedIndices = keyBinds.map((keyBind, index) => {
|
||||
const isDuplicated = entries.some(([otherTarget, otherKeyBinds]) => {
|
||||
if (otherTarget === target) return false;
|
||||
|
||||
return otherKeyBinds.some(kb => this.keyBindService.areKeyBindsEqual(keyBind, kb));
|
||||
});
|
||||
|
||||
return isDuplicated ? index : -1;
|
||||
})
|
||||
.filter(index => index !== -1) ?? [];
|
||||
|
||||
return [target, duplicatedIndices];
|
||||
})
|
||||
.filter(([_, indices]) => (indices as number[]).length > 0)
|
||||
) as Partial<Record<KeyBindTarget, number[]>>;
|
||||
}
|
||||
|
||||
private extractCustomKeyBinds(formValue: Partial<Record<KeyBindTarget, KeyBind[]>>): Partial<Record<KeyBindTarget, KeyBind[]>> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(formValue).filter(([target, keybinds]) =>
|
||||
!this.keyBindService.isDefaultKeyBinds(target as KeyBindTarget, keybinds)
|
||||
)
|
||||
) as Partial<Record<KeyBindTarget, KeyBind[]>>;
|
||||
}
|
||||
|
||||
private combinePreferences(customKeyBinds: Partial<Record<KeyBindTarget, KeyBind[]>>): Preferences {
|
||||
return {
|
||||
...this.accountService.currentUserSignal()!.preferences,
|
||||
customKeyBinds,
|
||||
};
|
||||
}
|
||||
|
||||
private toFormControls(keybinds: KeyBind[]): FormControl<KeyBind>[] {
|
||||
return keybinds.map(keyBind => this.fb.control(keyBind, this.keyBindValidator()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed getter for the FormArray of a given target
|
||||
* @param key
|
||||
*/
|
||||
getFormArray(key: KeyBindTarget): FormArray<FormControl<KeyBind>> | null {
|
||||
return this.keyBindForm.get(key) as FormArray<FormControl<KeyBind>> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset keybinds to default configured values
|
||||
* @param key
|
||||
*/
|
||||
resetKeybindsToDefaults(key: KeyBindTarget) {
|
||||
if (this.accountService.isReadOnly()) return;
|
||||
|
||||
this.keyBindForm.setControl(key, this.fb.array(this.toFormControls(DefaultKeyBinds[key]), this.keyBindArrayValidator()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new keybind option to the array, NOP if MAX_KEYBINDS_PER_TARGET has been reached
|
||||
* @param key
|
||||
*/
|
||||
addKeyBind(key: KeyBindTarget) {
|
||||
if (this.accountService.isReadOnly()) return;
|
||||
|
||||
const array = this.getFormArray(key);
|
||||
if (!array) return;
|
||||
|
||||
if (array.controls.length < MAX_KEYBINDS_PER_TARGET) {
|
||||
array.push(this.fb.control({key: KeyCode.Empty}, this.keyBindValidator()));
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const id = `key-bind-${key}-${array.length-1}`;
|
||||
const newElement = this.document.getElementById(id);
|
||||
if (newElement) {
|
||||
newElement.focus();
|
||||
}
|
||||
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a keybind from the array, if this is the last keybind. Resets to default
|
||||
* @param key
|
||||
* @param index
|
||||
*/
|
||||
removeKeyBind(key: KeyBindTarget, index: number) {
|
||||
if (this.accountService.isReadOnly()) return;
|
||||
|
||||
const array = this.getFormArray(key);
|
||||
if (!array) return;
|
||||
|
||||
if (array.controls.length === 1) {
|
||||
this.resetKeybindsToDefaults(key);
|
||||
} else {
|
||||
array.removeAt(index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator for FormControl<KeyBind>
|
||||
* @private
|
||||
*/
|
||||
private keyBindValidator(): ValidatorFn {
|
||||
return (control) => {
|
||||
const keyBind = (control as FormControl<KeyBind>).value;
|
||||
if (keyBind.key.length === 0 && !keyBind.controllerSequence) return { 'need-at-least-one-key': {'length': 0} } as ValidationErrors;
|
||||
|
||||
if (this.keyBindService.isReservedKeyBind(keyBind)) {
|
||||
return { 'reserved-key-bind': { 'keyBind': keyBind }} as ValidationErrors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private keyBindArrayValidator(): ValidatorFn {
|
||||
return (control) => {
|
||||
const controls = (control as FormArray<FormControl<KeyBind>>).controls;
|
||||
|
||||
const anyOverlap = controls.some((c, i) => controls.some((c2, i2)=> {
|
||||
return i !== i2 && this.keyBindService.areKeyBindsEqual(c.value, c2.value);
|
||||
}))
|
||||
|
||||
if (anyOverlap) {
|
||||
return { 'overlap-in-target': { '': '' } }
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined tooltip for FormControl<KeyBind> errors
|
||||
* @param target
|
||||
* @param index
|
||||
* @param errors
|
||||
* @protected
|
||||
*/
|
||||
protected errorToolTip(target: KeyBindTarget, index: number, errors: ValidationErrors | null): string | null {
|
||||
if (errors) {
|
||||
return Object.keys(errors)
|
||||
.map(key => this.transLoco.translate(`manage-custom-key-binds.key-bind-error-${key}`))
|
||||
.join(' ')
|
||||
.trim() || null;
|
||||
}
|
||||
|
||||
if (this.duplicatedKeyBinds()[target]?.includes(index)) {
|
||||
return this.transLoco.translate('manage-custom-key-binds.warning-duplicate-key-bind');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected readonly Object = Object;
|
||||
protected readonly MAX_KEYBINDS_PER_TARGET = MAX_KEYBINDS_PER_TARGET;
|
||||
}
|
||||
@ -223,6 +223,10 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
}
|
||||
|
||||
packSettings(): Preferences {
|
||||
return this.settingsForm.getRawValue();
|
||||
const customKeyBinds = this.accountService.currentUserSignal()!.preferences.customKeyBinds;
|
||||
return {
|
||||
customKeyBinds,
|
||||
...this.settingsForm.getRawValue(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
[libraryType]="libraryType"
|
||||
[mangaFormat]="series.format"
|
||||
[totalBytes]="size"
|
||||
[releaseYear]="(volume.chapters.at(0)?.releaseDate | utcToLocaleDate)?.getFullYear()"
|
||||
/>
|
||||
|
||||
@if (libraryType !== null && series && volume.chapters.length === 1) {
|
||||
|
||||
@ -85,6 +85,7 @@ import {User} from "../_models/user";
|
||||
import {AnnotationService} from "../_services/annotation.service";
|
||||
import {Annotation} from "../book-reader/_models/annotations/annotation";
|
||||
import {AnnotationsTabComponent} from "../_single-module/annotations-tab/annotations-tab.component";
|
||||
import {UtcToLocaleDatePipe} from "../_pipes/utc-to-locale-date.pipe";
|
||||
|
||||
enum TabID {
|
||||
|
||||
@ -158,7 +159,8 @@ interface VolumeCast extends IHasCast {
|
||||
CoverImageComponent,
|
||||
ReviewsComponent,
|
||||
ExternalRatingComponent,
|
||||
AnnotationsTabComponent
|
||||
AnnotationsTabComponent,
|
||||
UtcToLocaleDatePipe
|
||||
],
|
||||
templateUrl: './volume-detail.component.html',
|
||||
styleUrl: './volume-detail.component.scss',
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"provider-tooltip": "Provider settings require you to manually click Save. Kavita must be configured as a confidential client and needs a redirect URL. See the <a href='https://wiki.kavitareader.com/guides/admin-settings/open-id-connect/' target='_blank' rel='noreferrer noopener'>wiki</a> for more details.",
|
||||
"behavior-title": "Behavior",
|
||||
"other-field-required": "{{validation.other-field-required}}",
|
||||
"other-field-invalid": "{{validation.other-field-invalid}}",
|
||||
"invalid-uri": "{{validation.invalid-uri}}",
|
||||
"tls-required": "The OIDC provider must use tls (https)",
|
||||
"manual-save-label": "Changing provider settings requires a manual save",
|
||||
@ -1370,6 +1371,8 @@
|
||||
"remove-prefix-for-sortname-tooltip": "Kavita will remove common prefixes like 'The', 'A', 'An' from titles for sort name. Does not override set metadata.",
|
||||
"inherit-web-links-label": "Inherit web links from first chapter",
|
||||
"inherit-web-links-tooltip": "Should series inherit web links from their first chapter",
|
||||
"default-language-label": "Default language",
|
||||
"default-language-tooltip": "Language to assign to series, if none of the chapters have any language set in their metadata",
|
||||
"force-scan": "Force Scan",
|
||||
"force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan",
|
||||
"reset": "{{common.reset}}",
|
||||
@ -1958,6 +1961,7 @@
|
||||
"scrobble-holds": "Scrobble Holds",
|
||||
"account": "Account",
|
||||
"preferences": "Preferences",
|
||||
"custom-key-binds": "Key Binds",
|
||||
"reading-profiles": "Reading Profiles",
|
||||
"clients": "API Key / OPDS",
|
||||
"devices": "Devices",
|
||||
@ -1971,6 +1975,49 @@
|
||||
"admin-public-metadata": "Manage Metadata"
|
||||
},
|
||||
|
||||
"manage-custom-key-binds": {
|
||||
"description": "Sometimes clicking and dragging your mouse is just too slow. Use your preferred keybinds to speed things up. Each option allows for up to {{max}} different keybinds. <b> Long press a keybind to remove it. </b>",
|
||||
"key-bind-error-reserved-key-bind": "This keybind is reserved and cannot be used",
|
||||
"key-bind-error-need-at-least-one-key": "Your keybind must contain at least one key",
|
||||
"key-bind-error-overlap-in-target": "One or more keybinds are the same",
|
||||
"add": "Add alternative key",
|
||||
"reset": "Reset {{target}}",
|
||||
"errors-array": "Keybinds for {{target}} contain errors",
|
||||
"errors-control": "Keybind {{index}} for {{target}} contains errors",
|
||||
"warning-duplicated-control": "Keybind {{index}} for {{target}} has been used for an other target as well",
|
||||
"warning-duplicate-key-bind": "This keybind has also been used elsewhere",
|
||||
"key-bind-tooltip": "Long press a keybind to remove it.",
|
||||
|
||||
"global-header": "Global",
|
||||
"readers-header": "Readers"
|
||||
},
|
||||
|
||||
"keybind-setting-description-pipe": {
|
||||
"key-bind-title-navigate-to-settings": "Open settings",
|
||||
"key-bind-tooltip-navigate-to-settings": "Open settings while not in a reader",
|
||||
"key-bind-title-open-search": "Open search",
|
||||
"key-bind-tooltip-open-search": "Open the top search bar",
|
||||
"key-bind-title-navigate-to-scrobbling": "Open Scrobbling",
|
||||
"key-bind-tooltip-navigate-to-scrobbling": "Open settings page on the Scrobbling tab",
|
||||
"key-bind-title-escape": "Escape",
|
||||
"key-bind-tooltip-escape": "Close the currently open context",
|
||||
|
||||
"key-bind-title-toggle-fullscreen": "Toggle full screen",
|
||||
"key-bind-tooltip-toggle-fullscreen": "Alternative to F11",
|
||||
"key-bind-title-bookmark-page": "Bookmark current page",
|
||||
"key-bind-tooltip-bookmark-page": "Saves the current image as a bookmark",
|
||||
"key-bind-title-open-help": "Open help menu",
|
||||
"key-bind-tooltip-open-help": "Opens a help modal with all relevant keybinds",
|
||||
"key-bind-title-go-to": "Goto page",
|
||||
"key-bind-tooltip-go-to": "Open a prompt to switch pages",
|
||||
"key-bind-title-toggle-menu": "Toggle menu",
|
||||
"key-bind-tooltip-toggle-menu": "Toggles to reader menu",
|
||||
"key-bind-title-page-left": "Page left",
|
||||
"key-bind-tooltip-page-left": "Move one page to the left",
|
||||
"key-bind-title-page-right": "Page right",
|
||||
"key-bind-tooltip-page-right": "Move one page to the right"
|
||||
},
|
||||
|
||||
"collection-detail": {
|
||||
"no-data": "There are no items. Try adding a series.",
|
||||
"no-data-filtered": "No items match your current filter.",
|
||||
@ -2559,8 +2606,10 @@
|
||||
"role-label": "Role",
|
||||
"mal-id-label": "MAL Id",
|
||||
"mal-tooltip": "https://myanimelist.net/people/{MalId}/",
|
||||
"mal-tooltip-character": "https://myanimelist.net/character/{MalId}/",
|
||||
"anilist-id-label": "AniList Id",
|
||||
"anilist-tooltip": "https://anilist.co/staff/{AniListId}/",
|
||||
"anilist-tooltip-character": "https://anilist.co/character/{AniListId}/",
|
||||
"hardcover-id-label": "Hardcover Id",
|
||||
"hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}",
|
||||
"asin-label": "ASIN",
|
||||
@ -2720,6 +2769,7 @@
|
||||
"font-manual-upload": "There was an issue creating Font from manual upload",
|
||||
"font-already-in-use": "Font already exists by that name",
|
||||
"upload-too-large": "The file is too large for upload, select a smaller image and try again.",
|
||||
"invalid-form": "The form you're trying to submit contains errors",
|
||||
"import-fields": {
|
||||
"non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file",
|
||||
"non-unique-fields": "Field mappings do not have a unique id, please correct your import file"
|
||||
@ -3321,6 +3371,7 @@
|
||||
"validation": {
|
||||
"required-field": "This field is required",
|
||||
"other-field-required": "{{name}} is required when {{other}} is set",
|
||||
"other-field-invalid": "Cannot check validity, {{other}} is invalid",
|
||||
"valid-email": "This must be a valid email",
|
||||
"password-validation": "Password must be between 6 and 256 characters in length",
|
||||
"year-validation": "This must be a valid year greater than 1000 and 4 characters long",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user