mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Reading Profiles (#3845)
Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>
This commit is contained in:
parent
ea28d64302
commit
1856b01a46
124
API.Tests/Helpers/RandfHelper.cs
Normal file
124
API.Tests/Helpers/RandfHelper.cs
Normal file
@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace API.Tests.Helpers;
|
||||
|
||||
public class RandfHelper
|
||||
{
|
||||
private static readonly Random Random = new ();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if all simple fields are equal
|
||||
/// </summary>
|
||||
/// <param name="obj1"></param>
|
||||
/// <param name="obj2"></param>
|
||||
/// <param name="ignoreFields">fields to ignore, note that the names are very weird sometimes</param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="ArgumentNullException"></exception>
|
||||
/// <exception cref="ArgumentException"></exception>
|
||||
public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList<string> ignoreFields)
|
||||
{
|
||||
if (obj1 == null || obj2 == null)
|
||||
throw new ArgumentNullException("Neither object can be null.");
|
||||
|
||||
Type type1 = obj1.GetType();
|
||||
Type type2 = obj2.GetType();
|
||||
|
||||
if (type1 != type2)
|
||||
throw new ArgumentException("Objects must be of the same type.");
|
||||
|
||||
FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (field.IsInitOnly) continue;
|
||||
if (ignoreFields.Contains(field.Name)) continue;
|
||||
|
||||
Type fieldType = field.FieldType;
|
||||
|
||||
if (IsRelevantType(fieldType))
|
||||
{
|
||||
object value1 = field.GetValue(obj1);
|
||||
object value2 = field.GetValue(obj2);
|
||||
|
||||
if (!Equals(value1, value2))
|
||||
{
|
||||
throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsRelevantType(Type type)
|
||||
{
|
||||
return type.IsPrimitive
|
||||
|| type == typeof(string)
|
||||
|| type.IsEnum;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets all simple fields of the given object to a random value
|
||||
/// </summary>
|
||||
/// <param name="obj"></param>
|
||||
/// <remarks>Simple is, primitive, string, or enum</remarks>
|
||||
/// <exception cref="ArgumentNullException"></exception>
|
||||
public static void SetRandomValues(object obj)
|
||||
{
|
||||
if (obj == null) throw new ArgumentNullException(nameof(obj));
|
||||
|
||||
Type type = obj.GetType();
|
||||
FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (field.IsInitOnly) continue; // Skip readonly fields
|
||||
|
||||
object value = GenerateRandomValue(field.FieldType);
|
||||
if (value != null)
|
||||
{
|
||||
field.SetValue(obj, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static object GenerateRandomValue(Type type)
|
||||
{
|
||||
if (type == typeof(int))
|
||||
return Random.Next();
|
||||
if (type == typeof(float))
|
||||
return (float)Random.NextDouble() * 100;
|
||||
if (type == typeof(double))
|
||||
return Random.NextDouble() * 100;
|
||||
if (type == typeof(bool))
|
||||
return Random.Next(2) == 1;
|
||||
if (type == typeof(char))
|
||||
return (char)Random.Next('A', 'Z' + 1);
|
||||
if (type == typeof(byte))
|
||||
return (byte)Random.Next(0, 256);
|
||||
if (type == typeof(short))
|
||||
return (short)Random.Next(short.MinValue, short.MaxValue);
|
||||
if (type == typeof(long))
|
||||
return (long)(Random.NextDouble() * long.MaxValue);
|
||||
if (type == typeof(string))
|
||||
return GenerateRandomString(10);
|
||||
if (type.IsEnum)
|
||||
{
|
||||
var values = Enum.GetValues(type);
|
||||
return values.GetValue(Random.Next(values.Length));
|
||||
}
|
||||
|
||||
// Unsupported type
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GenerateRandomString(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
return new string(Enumerable.Repeat(chars, length)
|
||||
.Select(s => s[Random.Next(s.Length)]).ToArray());
|
||||
}
|
||||
}
|
561
API.Tests/Services/ReadingProfileServiceTest.cs
Normal file
561
API.Tests/Services/ReadingProfileServiceTest.cs
Normal file
@ -0,0 +1,561 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Tests.Helpers;
|
||||
using Kavita.Common;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class ReadingProfileServiceTest: AbstractDbTest
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Does not add a default reading profile
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup()
|
||||
{
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
|
||||
Context.AppUser.Add(user);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var series = new SeriesBuilder("Spice and Wolf").Build();
|
||||
|
||||
var library = new LibraryBuilder("Manga")
|
||||
.WithSeries(series)
|
||||
.Build();
|
||||
|
||||
user.Libraries.Add(library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var rps = new ReadingProfileService(UnitOfWork, Substitute.For<ILocalizationService>(), Mapper);
|
||||
user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences);
|
||||
|
||||
return (rps, user, library, series);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImplicitProfileFirst()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, library, series) = await Setup();
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithKind(ReadingProfileKind.Implicit)
|
||||
.WithSeries(series)
|
||||
.WithName("Implicit Profile")
|
||||
.Build();
|
||||
|
||||
var profile2 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Non-implicit Profile")
|
||||
.Build();
|
||||
|
||||
user.ReadingProfiles.Add(profile);
|
||||
user.ReadingProfiles.Add(profile2);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.Equal("Implicit Profile", seriesProfile.Name);
|
||||
|
||||
// Find parent
|
||||
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.Equal("Non-implicit Profile", seriesProfile.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CantDeleteDefaultReadingProfile()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, _, _) = await Setup();
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithKind(ReadingProfileKind.Default)
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(async () =>
|
||||
{
|
||||
await rps.DeleteReadingProfile(user.Id, profile.Id);
|
||||
});
|
||||
|
||||
var profile2 = new AppUserReadingProfileBuilder(user.Id).Build();
|
||||
Context.AppUserReadingProfiles.Add(profile2);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.DeleteReadingProfile(user.Id, profile2.Id);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var allProfiles = await Context.AppUserReadingProfiles.ToListAsync();
|
||||
Assert.Single(allProfiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateImplicitSeriesReadingProfile()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, _, series) = await Setup();
|
||||
|
||||
var dto = new UserReadingProfileDto
|
||||
{
|
||||
ReaderMode = ReaderMode.Webtoon,
|
||||
ScalingOption = ScalingOption.FitToHeight,
|
||||
WidthOverride = 53,
|
||||
};
|
||||
|
||||
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
|
||||
|
||||
var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Contains(profile.SeriesIds, s => s == series.Id);
|
||||
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateImplicitReadingProfile_DoesNotCreateNew()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, _, series) = await Setup();
|
||||
|
||||
var dto = new UserReadingProfileDto
|
||||
{
|
||||
ReaderMode = ReaderMode.Webtoon,
|
||||
ScalingOption = ScalingOption.FitToHeight,
|
||||
WidthOverride = 53,
|
||||
};
|
||||
|
||||
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
|
||||
|
||||
var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Contains(profile.SeriesIds, s => s == series.Id);
|
||||
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
|
||||
|
||||
dto = new UserReadingProfileDto
|
||||
{
|
||||
ReaderMode = ReaderMode.LeftRight,
|
||||
};
|
||||
|
||||
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
|
||||
profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Contains(profile.SeriesIds, s => s == series.Id);
|
||||
Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
|
||||
Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode);
|
||||
|
||||
var implicitCount = await Context.AppUserReadingProfiles
|
||||
.Where(p => p.Kind == ReadingProfileKind.Implicit)
|
||||
.CountAsync();
|
||||
Assert.Equal(1, implicitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCorrectProfile()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Series Specific")
|
||||
.Build();
|
||||
var profile2 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithLibrary(lib)
|
||||
.WithName("Library Specific")
|
||||
.Build();
|
||||
var profile3 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithKind(ReadingProfileKind.Default)
|
||||
.WithName("Global")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile);
|
||||
Context.AppUserReadingProfiles.Add(profile2);
|
||||
Context.AppUserReadingProfiles.Add(profile3);
|
||||
|
||||
var series2 = new SeriesBuilder("Rainbows After Storms").Build();
|
||||
lib.Series.Add(series2);
|
||||
|
||||
var lib2 = new LibraryBuilder("Manga2").Build();
|
||||
var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build();
|
||||
lib2.Series.Add(series3);
|
||||
|
||||
user.Libraries.Add(lib2);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal("Series Specific", p.Name);
|
||||
|
||||
p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id);
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal("Library Specific", p.Name);
|
||||
|
||||
p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id);
|
||||
Assert.NotNull(p);
|
||||
Assert.Equal("Global", p.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaceReadingProfile()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var profile1 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Profile 1")
|
||||
.Build();
|
||||
|
||||
var profile2 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Profile 2")
|
||||
.Build();
|
||||
|
||||
Context.AppUserReadingProfiles.Add(profile1);
|
||||
Context.AppUserReadingProfiles.Add(profile2);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("Profile 1", profile.Name);
|
||||
|
||||
await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id);
|
||||
profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(profile);
|
||||
Assert.Equal("Profile 2", profile.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteReadingProfile()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var profile1 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Profile 1")
|
||||
.Build();
|
||||
|
||||
Context.AppUserReadingProfiles.Add(profile1);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.ClearSeriesProfile(user.Id, series.Id);
|
||||
var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
|
||||
Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id));
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkAddReadingProfiles()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
|
||||
lib.Series.Add(generatedSeries);
|
||||
}
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Profile")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile);
|
||||
|
||||
var profile2 = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Profile2")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile2);
|
||||
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList();
|
||||
await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds);
|
||||
|
||||
foreach (var id in someSeriesIds)
|
||||
{
|
||||
var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
|
||||
Assert.NotNull(foundProfile);
|
||||
Assert.Equal(profile.Id, foundProfile.Id);
|
||||
}
|
||||
|
||||
var allIds = lib.Series.Select(s => s.Id).ToList();
|
||||
await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds);
|
||||
|
||||
foreach (var id in allIds)
|
||||
{
|
||||
var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
|
||||
Assert.NotNull(foundProfile);
|
||||
Assert.Equal(profile2.Id, foundProfile.Id);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkAssignDeletesImplicit()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
|
||||
.Build());
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Profile 1")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
|
||||
lib.Series.Add(generatedSeries);
|
||||
}
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var ids = lib.Series.Select(s => s.Id).ToList();
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile);
|
||||
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
|
||||
}
|
||||
|
||||
await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids);
|
||||
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
|
||||
}
|
||||
|
||||
var implicitCount = await Context.AppUserReadingProfiles
|
||||
.Where(p => p.Kind == ReadingProfileKind.Implicit)
|
||||
.CountAsync();
|
||||
Assert.Equal(0, implicitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddDeletesImplicit()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var implicitProfile = Mapper.Map<UserReadingProfileDto>(new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithKind(ReadingProfileKind.Implicit)
|
||||
.Build());
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Profile 1")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile);
|
||||
|
||||
var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
|
||||
|
||||
await rps.AddProfileToSeries(user.Id, profile.Id, series.Id);
|
||||
|
||||
seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
|
||||
Assert.NotNull(seriesProfile);
|
||||
Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
|
||||
|
||||
var implicitCount = await Context.AppUserReadingProfiles
|
||||
.Where(p => p.Kind == ReadingProfileKind.Implicit)
|
||||
.CountAsync();
|
||||
Assert.Equal(0, implicitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateReadingProfile()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, series) = await Setup();
|
||||
|
||||
var dto = new UserReadingProfileDto
|
||||
{
|
||||
Name = "Profile 1",
|
||||
ReaderMode = ReaderMode.LeftRight,
|
||||
EmulateBook = false,
|
||||
};
|
||||
|
||||
await rps.CreateReadingProfile(user.Id, dto);
|
||||
|
||||
var dto2 = new UserReadingProfileDto
|
||||
{
|
||||
Name = "Profile 2",
|
||||
ReaderMode = ReaderMode.LeftRight,
|
||||
EmulateBook = false,
|
||||
};
|
||||
|
||||
await rps.CreateReadingProfile(user.Id, dto2);
|
||||
|
||||
var dto3 = new UserReadingProfileDto
|
||||
{
|
||||
Name = "Profile 1", // Not unique name
|
||||
ReaderMode = ReaderMode.LeftRight,
|
||||
EmulateBook = false,
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(async () =>
|
||||
{
|
||||
await rps.CreateReadingProfile(user.Id, dto3);
|
||||
});
|
||||
|
||||
var allProfiles = Context.AppUserReadingProfiles.ToList();
|
||||
Assert.Equal(2, allProfiles.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, _, series) = await Setup();
|
||||
|
||||
var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithKind(ReadingProfileKind.Implicit)
|
||||
.WithName("Implicit Profile")
|
||||
.Build();
|
||||
|
||||
var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithSeries(series)
|
||||
.WithName("Explicit Profile")
|
||||
.Build();
|
||||
|
||||
Context.AppUserReadingProfiles.Add(implicitProfile);
|
||||
Context.AppUserReadingProfiles.Add(explicitProfile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
|
||||
Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id)));
|
||||
|
||||
await rps.ClearSeriesProfile(user.Id, series.Id);
|
||||
|
||||
var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync();
|
||||
Assert.Single(remainingProfiles);
|
||||
Assert.Equal("Explicit Profile", remainingProfiles[0].Name);
|
||||
Assert.Empty(remainingProfiles[0].SeriesIds);
|
||||
|
||||
var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
|
||||
Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddProfileToLibrary_AddsAndOverridesExisting()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, _) = await Setup();
|
||||
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Library Profile")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(profile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
|
||||
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
|
||||
Assert.NotNull(linkedProfile);
|
||||
Assert.Equal(profile.Id, linkedProfile.Id);
|
||||
|
||||
var newProfile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("New Profile")
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(newProfile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
|
||||
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
|
||||
Assert.NotNull(linkedProfile);
|
||||
Assert.Equal(newProfile.Id, linkedProfile.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit()
|
||||
{
|
||||
await ResetDb();
|
||||
var (rps, user, lib, _) = await Setup();
|
||||
|
||||
var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithKind(ReadingProfileKind.Implicit)
|
||||
.WithLibrary(lib)
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(implicitProfile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.ClearLibraryProfile(user.Id, lib.Id);
|
||||
var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
|
||||
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
|
||||
Assert.Null(profile);
|
||||
|
||||
var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithLibrary(lib)
|
||||
.Build();
|
||||
Context.AppUserReadingProfiles.Add(explicitProfile);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await rps.ClearLibraryProfile(user.Id, lib.Id);
|
||||
profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
|
||||
.FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
|
||||
Assert.Null(profile);
|
||||
|
||||
var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id);
|
||||
Assert.NotNull(stillExists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test
|
||||
/// is worth having.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void UpdateFields_UpdatesAll()
|
||||
{
|
||||
// Repeat to ensure booleans are flipped and actually tested
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var profile = new AppUserReadingProfile();
|
||||
var dto = new UserReadingProfileDto();
|
||||
|
||||
RandfHelper.SetRandomValues(profile);
|
||||
RandfHelper.SetRandomValues(dto);
|
||||
|
||||
ReadingProfileService.UpdateReaderProfileFields(profile, dto);
|
||||
|
||||
var newDto = Mapper.Map<UserReadingProfileDto>(profile);
|
||||
|
||||
Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto,
|
||||
["<Id>k__BackingField", "<UserId>k__BackingField"]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles);
|
||||
await UnitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
@ -153,6 +153,9 @@ public class AccountController : BaseApiController
|
||||
// Assign default streams
|
||||
AddDefaultStreamsToUser(user);
|
||||
|
||||
// Assign default reading profile
|
||||
await AddDefaultReadingProfileToUser(user);
|
||||
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
|
||||
if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token));
|
||||
@ -609,7 +612,7 @@ public class AccountController : BaseApiController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests the Invite Url for the UserId. Will return error if user is already validated.
|
||||
/// Requests the Invite Url for the AppUserId. Will return error if user is already validated.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="withBaseUrl">Include the "https://ip:port/" in the generated link</param>
|
||||
@ -669,6 +672,9 @@ public class AccountController : BaseApiController
|
||||
// Assign default streams
|
||||
AddDefaultStreamsToUser(user);
|
||||
|
||||
// Assign default reading profile
|
||||
await AddDefaultReadingProfileToUser(user);
|
||||
|
||||
// Assign Roles
|
||||
var roles = dto.Roles;
|
||||
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
|
||||
@ -779,6 +785,16 @@ public class AccountController : BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddDefaultReadingProfileToUser(AppUser user)
|
||||
{
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Default Profile")
|
||||
.WithKind(ReadingProfileKind.Default)
|
||||
.Build();
|
||||
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last step in authentication flow, confirms the email token for email
|
||||
/// </summary>
|
||||
|
@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
|
||||
throw new KavitaUnauthenticatedUserException();
|
||||
}
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
|
||||
logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
|
||||
|
||||
return new UserDto
|
||||
{
|
||||
|
198
API/Controllers/ReadingProfileController.cs
Normal file
198
API/Controllers/ReadingProfileController.cs
Normal file
@ -0,0 +1,198 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
[Route("api/reading-profile")]
|
||||
public class ReadingProfileController(ILogger<ReadingProfileController> logger, IUnitOfWork unitOfWork,
|
||||
IReadingProfileService readingProfileService): BaseApiController
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Gets all non-implicit reading profiles for a user
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("all")]
|
||||
public async Task<ActionResult<IList<UserReadingProfileDto>>> GetAllReadingProfiles()
|
||||
{
|
||||
return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesDtoForUser(User.GetUserId(), true));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the ReadingProfile that should be applied to the given series, walks up the tree.
|
||||
/// Series -> Library -> Default
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="skipImplicit"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{seriesId:int}")]
|
||||
public async Task<ActionResult<UserReadingProfileDto>> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit)
|
||||
{
|
||||
return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the (potential) Reading Profile bound to the library
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("library")]
|
||||
public async Task<ActionResult<UserReadingProfileDto?>> GetProfileForLibrary(int libraryId)
|
||||
{
|
||||
return Ok(await readingProfileService.GetReadingProfileDtoForLibrary(User.GetUserId(), libraryId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new reading profile for the current user
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("create")]
|
||||
public async Task<ActionResult<UserReadingProfileDto>> CreateReadingProfile([FromBody] UserReadingProfileDto dto)
|
||||
{
|
||||
return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotes the implicit profile to a user profile. Removes the series from other profiles
|
||||
/// </summary>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("promote")]
|
||||
public async Task<ActionResult<UserReadingProfileDto>> PromoteImplicitReadingProfile([FromQuery] int profileId)
|
||||
{
|
||||
return Ok(await readingProfileService.PromoteImplicitProfile(User.GetUserId(), profileId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the implicit reading profile for a series, creates one if none exists
|
||||
/// </summary>
|
||||
/// <remarks>Any modification to the reader settings during reading will create an implicit profile. Use "update-parent" to save to the bound series profile.</remarks>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("series")]
|
||||
public async Task<ActionResult<UserReadingProfileDto>> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
|
||||
{
|
||||
var updatedProfile = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto);
|
||||
return Ok(updatedProfile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the non-implicit reading profile for the given series, and removes implicit profiles
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update-parent")]
|
||||
public async Task<ActionResult<UserReadingProfileDto>> UpdateParentProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
|
||||
{
|
||||
var newParentProfile = await readingProfileService.UpdateParent(User.GetUserId(), seriesId, dto);
|
||||
return Ok(newParentProfile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the given reading profile, must belong to the current user
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns>The updated reading profile</returns>
|
||||
/// <remarks>
|
||||
/// This does not update connected series and libraries.
|
||||
/// </remarks>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserReadingProfileDto>> UpdateReadingProfile(UserReadingProfileDto dto)
|
||||
{
|
||||
return Ok(await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the given profile, requires the profile to belong to the logged-in user
|
||||
/// </summary>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
/// <exception cref="UnauthorizedAccessException"></exception>
|
||||
[HttpDelete]
|
||||
public async Task<IActionResult> DeleteReadingProfile([FromQuery] int profileId)
|
||||
{
|
||||
await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the reading profile for a given series, removes the old one
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("series/{seriesId:int}")]
|
||||
public async Task<IActionResult> AddProfileToSeries(int seriesId, [FromQuery] int profileId)
|
||||
{
|
||||
await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the reading profile for the given series for the currently logged-in user
|
||||
/// </summary>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete("series/{seriesId:int}")]
|
||||
public async Task<IActionResult> ClearSeriesProfile(int seriesId)
|
||||
{
|
||||
await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the reading profile for a given library, removes the old one
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("library/{libraryId:int}")]
|
||||
public async Task<IActionResult> AddProfileToLibrary(int libraryId, [FromQuery] int profileId)
|
||||
{
|
||||
await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the reading profile for the given library for the currently logged-in user
|
||||
/// </summary>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete("library/{libraryId:int}")]
|
||||
public async Task<IActionResult> ClearLibraryProfile(int libraryId)
|
||||
{
|
||||
await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns the reading profile to all passes series, and deletes their implicit profiles
|
||||
/// </summary>
|
||||
/// <param name="profileId"></param>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("bulk")]
|
||||
public async Task<IActionResult> BulkAddReadingProfile([FromQuery] int profileId, [FromBody] IList<int> seriesIds)
|
||||
{
|
||||
await readingProfileService.BulkAddProfileToSeries(User.GetUserId(), profileId, seriesIds);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
}
|
@ -103,39 +103,13 @@ public class UsersController : BaseApiController
|
||||
|
||||
var existingPreferences = user!.UserPreferences;
|
||||
|
||||
existingPreferences.ReadingDirection = preferencesDto.ReadingDirection;
|
||||
existingPreferences.ScalingOption = preferencesDto.ScalingOption;
|
||||
existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
|
||||
existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
|
||||
existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
|
||||
existingPreferences.EmulateBook = preferencesDto.EmulateBook;
|
||||
existingPreferences.ReaderMode = preferencesDto.ReaderMode;
|
||||
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
|
||||
existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor;
|
||||
existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin;
|
||||
existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing;
|
||||
existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily;
|
||||
existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize;
|
||||
existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
|
||||
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
|
||||
existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle;
|
||||
existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
|
||||
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
|
||||
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
|
||||
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
|
||||
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
|
||||
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
|
||||
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
|
||||
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
|
||||
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
|
||||
existingPreferences.AllowAutomaticWebtoonReaderDetection = preferencesDto.AllowAutomaticWebtoonReaderDetection;
|
||||
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
|
||||
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
|
||||
|
||||
existingPreferences.PdfTheme = preferencesDto.PdfTheme;
|
||||
existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
|
||||
existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
|
||||
|
||||
if (await _licenseService.HasActiveLicense())
|
||||
{
|
||||
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;
|
||||
|
@ -9,61 +9,6 @@ namespace API.DTOs;
|
||||
|
||||
public sealed record UserPreferencesDto
|
||||
{
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.ReadingDirection"/>
|
||||
[Required]
|
||||
public ReadingDirection ReadingDirection { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.ScalingOption"/>
|
||||
[Required]
|
||||
public ScalingOption ScalingOption { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.PageSplitOption"/>
|
||||
[Required]
|
||||
public PageSplitOption PageSplitOption { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.ReaderMode"/>
|
||||
[Required]
|
||||
public ReaderMode ReaderMode { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.LayoutMode"/>
|
||||
[Required]
|
||||
public LayoutMode LayoutMode { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.EmulateBook"/>
|
||||
[Required]
|
||||
public bool EmulateBook { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BackgroundColor"/>
|
||||
[Required]
|
||||
public string BackgroundColor { get; set; } = "#000000";
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.SwipeToPaginate"/>
|
||||
[Required]
|
||||
public bool SwipeToPaginate { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.AutoCloseMenu"/>
|
||||
[Required]
|
||||
public bool AutoCloseMenu { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.ShowScreenHints"/>
|
||||
[Required]
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.AllowAutomaticWebtoonReaderDetection"/>
|
||||
[Required]
|
||||
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderMargin"/>
|
||||
[Required]
|
||||
public int BookReaderMargin { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderLineSpacing"/>
|
||||
[Required]
|
||||
public int BookReaderLineSpacing { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderFontSize"/>
|
||||
[Required]
|
||||
public int BookReaderFontSize { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderFontFamily"/>
|
||||
[Required]
|
||||
public string BookReaderFontFamily { get; set; } = null!;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderTapToPaginate"/>
|
||||
[Required]
|
||||
public bool BookReaderTapToPaginate { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderReadingDirection"/>
|
||||
[Required]
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderWritingStyle"/>
|
||||
[Required]
|
||||
public WritingStyle BookReaderWritingStyle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// UI Site Global Setting: The UI theme the user should use.
|
||||
@ -72,15 +17,6 @@ public sealed record UserPreferencesDto
|
||||
[Required]
|
||||
public SiteThemeDto? Theme { get; set; }
|
||||
|
||||
[Required] public string BookReaderThemeName { get; set; } = null!;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderLayoutMode"/>
|
||||
[Required]
|
||||
public BookPageLayoutMode BookReaderLayoutMode { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BookReaderImmersiveMode"/>
|
||||
[Required]
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.GlobalPageLayoutMode"/>
|
||||
[Required]
|
||||
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.BlurUnreadSummaries"/>
|
||||
[Required]
|
||||
@ -101,16 +37,6 @@ public sealed record UserPreferencesDto
|
||||
[Required]
|
||||
public string Locale { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.PdfTheme"/>
|
||||
[Required]
|
||||
public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.PdfScrollMode"/>
|
||||
[Required]
|
||||
public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.PdfSpreadMode"/>
|
||||
[Required]
|
||||
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.AniListScrobblingEnabled"/>
|
||||
public bool AniListScrobblingEnabled { get; set; }
|
||||
/// <inheritdoc cref="API.Entities.AppUserPreferences.WantToReadSync"/>
|
||||
|
129
API/DTOs/UserReadingProfileDto.cs
Normal file
129
API/DTOs/UserReadingProfileDto.cs
Normal file
@ -0,0 +1,129 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
public sealed record UserReadingProfileDto
|
||||
{
|
||||
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; init; }
|
||||
|
||||
public string Name { get; init; }
|
||||
public ReadingProfileKind Kind { get; init; }
|
||||
|
||||
#region MangaReader
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.ReadingDirection"/>
|
||||
[Required]
|
||||
public ReadingDirection ReadingDirection { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.ScalingOption"/>
|
||||
[Required]
|
||||
public ScalingOption ScalingOption { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.PageSplitOption"/>
|
||||
[Required]
|
||||
public PageSplitOption PageSplitOption { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.ReaderMode"/>
|
||||
[Required]
|
||||
public ReaderMode ReaderMode { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.AutoCloseMenu"/>
|
||||
[Required]
|
||||
public bool AutoCloseMenu { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.ShowScreenHints"/>
|
||||
[Required]
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.EmulateBook"/>
|
||||
[Required]
|
||||
public bool EmulateBook { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.LayoutMode"/>
|
||||
[Required]
|
||||
public LayoutMode LayoutMode { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BackgroundColor"/>
|
||||
[Required]
|
||||
public string BackgroundColor { get; set; } = "#000000";
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.SwipeToPaginate"/>
|
||||
[Required]
|
||||
public bool SwipeToPaginate { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.AllowAutomaticWebtoonReaderDetection"/>
|
||||
[Required]
|
||||
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
|
||||
|
||||
/// <inheritdoc cref="AppUserReadingProfile.WidthOverride"/>
|
||||
public int? WidthOverride { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region EpubReader
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderMargin"/>
|
||||
[Required]
|
||||
public int BookReaderMargin { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderLineSpacing"/>
|
||||
[Required]
|
||||
public int BookReaderLineSpacing { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderFontSize"/>
|
||||
[Required]
|
||||
public int BookReaderFontSize { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderFontFamily"/>
|
||||
[Required]
|
||||
public string BookReaderFontFamily { get; set; } = null!;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderTapToPaginate"/>
|
||||
[Required]
|
||||
public bool BookReaderTapToPaginate { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderReadingDirection"/>
|
||||
[Required]
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderWritingStyle"/>
|
||||
[Required]
|
||||
public WritingStyle BookReaderWritingStyle { get; set; }
|
||||
|
||||
/// <inheritdoc cref="AppUserReadingProfile.BookThemeName"/>
|
||||
[Required]
|
||||
public string BookReaderThemeName { get; set; } = null!;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderLayoutMode"/>
|
||||
[Required]
|
||||
public BookPageLayoutMode BookReaderLayoutMode { get; set; }
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.BookReaderImmersiveMode"/>
|
||||
[Required]
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
|
||||
#endregion
|
||||
|
||||
#region PdfReader
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.PdfTheme"/>
|
||||
[Required]
|
||||
public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.PdfScrollMode"/>
|
||||
[Required]
|
||||
public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
|
||||
|
||||
/// <inheritdoc cref="API.Entities.AppUserReadingProfile.PdfSpreadMode"/>
|
||||
[Required]
|
||||
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
@ -81,6 +81,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
public DbSet<MetadataSettings> MetadataSettings { get; set; } = null!;
|
||||
public DbSet<MetadataFieldMapping> MetadataFieldMapping { get; set; } = null!;
|
||||
public DbSet<AppUserChapterRating> AppUserChapterRating { get; set; } = null!;
|
||||
public DbSet<AppUserReadingProfile> AppUserReadingProfiles { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@ -256,6 +257,32 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
builder.Entity<MetadataSettings>()
|
||||
.Property(b => b.EnableCoverImage)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Entity<AppUserReadingProfile>()
|
||||
.Property(b => b.BookThemeName)
|
||||
.HasDefaultValue("Dark");
|
||||
builder.Entity<AppUserReadingProfile>()
|
||||
.Property(b => b.BackgroundColor)
|
||||
.HasDefaultValue("#000000");
|
||||
builder.Entity<AppUserReadingProfile>()
|
||||
.Property(b => b.BookReaderWritingStyle)
|
||||
.HasDefaultValue(WritingStyle.Horizontal);
|
||||
builder.Entity<AppUserReadingProfile>()
|
||||
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
|
||||
.HasDefaultValue(true);
|
||||
|
||||
builder.Entity<AppUserReadingProfile>()
|
||||
.Property(rp => rp.LibraryIds)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
||||
v => JsonSerializer.Deserialize<List<int>>(v, JsonSerializerOptions.Default) ?? new List<int>())
|
||||
.HasColumnType("TEXT");
|
||||
builder.Entity<AppUserReadingProfile>()
|
||||
.Property(rp => rp.SeriesIds)
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
|
||||
v => JsonSerializer.Deserialize<List<int>>(v, JsonSerializerOptions.Default) ?? new List<int>())
|
||||
.HasColumnType("TEXT");
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
|
@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.History;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
public static class ManualMigrateReadingProfiles
|
||||
{
|
||||
public static async Task Migrate(DataContext context, ILogger<Program> logger)
|
||||
{
|
||||
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateReadingProfiles"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical("Running ManualMigrateReadingProfiles migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
var users = await context.AppUser
|
||||
.Include(u => u.UserPreferences)
|
||||
.Include(u => u.ReadingProfiles)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var readingProfile = new AppUserReadingProfile
|
||||
{
|
||||
Name = "Default",
|
||||
NormalizedName = "Default".ToNormalized(),
|
||||
Kind = ReadingProfileKind.Default,
|
||||
LibraryIds = [],
|
||||
SeriesIds = [],
|
||||
BackgroundColor = user.UserPreferences.BackgroundColor,
|
||||
EmulateBook = user.UserPreferences.EmulateBook,
|
||||
AppUser = user,
|
||||
PdfTheme = user.UserPreferences.PdfTheme,
|
||||
ReaderMode = user.UserPreferences.ReaderMode,
|
||||
ReadingDirection = user.UserPreferences.ReadingDirection,
|
||||
ScalingOption = user.UserPreferences.ScalingOption,
|
||||
LayoutMode = user.UserPreferences.LayoutMode,
|
||||
WidthOverride = null,
|
||||
AppUserId = user.Id,
|
||||
AutoCloseMenu = user.UserPreferences.AutoCloseMenu,
|
||||
BookReaderMargin = user.UserPreferences.BookReaderMargin,
|
||||
PageSplitOption = user.UserPreferences.PageSplitOption,
|
||||
BookThemeName = user.UserPreferences.BookThemeName,
|
||||
PdfSpreadMode = user.UserPreferences.PdfSpreadMode,
|
||||
PdfScrollMode = user.UserPreferences.PdfScrollMode,
|
||||
SwipeToPaginate = user.UserPreferences.SwipeToPaginate,
|
||||
BookReaderFontFamily = user.UserPreferences.BookReaderFontFamily,
|
||||
BookReaderFontSize = user.UserPreferences.BookReaderFontSize,
|
||||
BookReaderImmersiveMode = user.UserPreferences.BookReaderImmersiveMode,
|
||||
BookReaderLayoutMode = user.UserPreferences.BookReaderLayoutMode,
|
||||
BookReaderLineSpacing = user.UserPreferences.BookReaderLineSpacing,
|
||||
BookReaderReadingDirection = user.UserPreferences.BookReaderReadingDirection,
|
||||
BookReaderWritingStyle = user.UserPreferences.BookReaderWritingStyle,
|
||||
AllowAutomaticWebtoonReaderDetection = user.UserPreferences.AllowAutomaticWebtoonReaderDetection,
|
||||
BookReaderTapToPaginate = user.UserPreferences.BookReaderTapToPaginate,
|
||||
ShowScreenHints = user.UserPreferences.ShowScreenHints,
|
||||
};
|
||||
user.ReadingProfiles.Add(readingProfile);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
context.ManualMigrationHistory.Add(new ManualMigrationHistory
|
||||
{
|
||||
Name = "ManualMigrateReadingProfiles",
|
||||
ProductVersion = BuildInfo.Version.ToString(),
|
||||
RanAt = DateTime.UtcNow,
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
|
||||
logger.LogCritical("Running ManualMigrateReadingProfiles migration - Completed. This is not an error");
|
||||
|
||||
}
|
||||
}
|
3698
API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs
generated
Normal file
3698
API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
API/Data/Migrations/20250601200056_ReadingProfiles.cs
Normal file
75
API/Data/Migrations/20250601200056_ReadingProfiles.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReadingProfiles : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserReadingProfiles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Kind = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LibraryIds = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SeriesIds = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ReadingDirection = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ScalingOption = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PageSplitOption = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ReaderMode = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AutoCloseMenu = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
ShowScreenHints = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
EmulateBook = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
LayoutMode = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BackgroundColor = table.Column<string>(type: "TEXT", nullable: true, defaultValue: "#000000"),
|
||||
SwipeToPaginate = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
AllowAutomaticWebtoonReaderDetection = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true),
|
||||
WidthOverride = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
BookReaderMargin = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BookReaderLineSpacing = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BookReaderFontSize = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BookReaderFontFamily = table.Column<string>(type: "TEXT", nullable: true),
|
||||
BookReaderTapToPaginate = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
BookReaderReadingDirection = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BookReaderWritingStyle = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||
BookThemeName = table.Column<string>(type: "TEXT", nullable: true, defaultValue: "Dark"),
|
||||
BookReaderLayoutMode = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BookReaderImmersiveMode = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
PdfTheme = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PdfScrollMode = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
PdfSpreadMode = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserReadingProfiles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserReadingProfiles_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserReadingProfiles_AppUserId",
|
||||
table: "AppUserReadingProfiles",
|
||||
column: "AppUserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserReadingProfiles");
|
||||
}
|
||||
}
|
||||
}
|
@ -609,6 +609,120 @@ namespace API.Data.Migrations
|
||||
b.ToTable("AppUserRating");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AllowAutomaticWebtoonReaderDetection")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AutoCloseMenu")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackgroundColor")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("#000000");
|
||||
|
||||
b.Property<string>("BookReaderFontFamily")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("BookReaderFontSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BookReaderImmersiveMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderLineSpacing")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderMargin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderReadingDirection")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("BookReaderTapToPaginate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookReaderWritingStyle")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("BookThemeName")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("Dark");
|
||||
|
||||
b.Property<bool>("EmulateBook")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("LayoutMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LibraryIds")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PageSplitOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PdfScrollMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PdfSpreadMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PdfTheme")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReaderMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ReadingDirection")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ScalingOption")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.PrimitiveCollection<string>("SeriesIds")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ShowScreenHints")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SwipeToPaginate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("WidthOverride")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserReadingProfiles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||
{
|
||||
b.Property<int>("UserId")
|
||||
@ -2838,6 +2952,17 @@ namespace API.Data.Migrations
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("ReadingProfiles")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppRole", "Role")
|
||||
@ -3476,6 +3601,8 @@ namespace API.Data.Migrations
|
||||
|
||||
b.Navigation("ReadingLists");
|
||||
|
||||
b.Navigation("ReadingProfiles");
|
||||
|
||||
b.Navigation("ScrobbleHolds");
|
||||
|
||||
b.Navigation("SideNavStreams");
|
||||
|
112
API/Data/Repositories/AppUserReadingProfileRepository.cs
Normal file
112
API/Data/Repositories/AppUserReadingProfileRepository.cs
Normal file
@ -0,0 +1,112 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Extensions.QueryExtensions;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
|
||||
public interface IAppUserReadingProfileRepository
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Return the given profile if it belongs the user
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
Task<AppUserReadingProfile?> GetUserProfile(int userId, int profileId);
|
||||
/// <summary>
|
||||
/// Returns all reading profiles for the user
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
Task<IList<AppUserReadingProfile>> GetProfilesForUser(int userId, bool skipImplicit = false);
|
||||
/// <summary>
|
||||
/// Returns all reading profiles for the user
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
Task<IList<UserReadingProfileDto>> GetProfilesDtoForUser(int userId, bool skipImplicit = false);
|
||||
/// <summary>
|
||||
/// Is there a user reading profile with this name (normalized)
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> IsProfileNameInUse(int userId, string name);
|
||||
|
||||
void Add(AppUserReadingProfile readingProfile);
|
||||
void Update(AppUserReadingProfile readingProfile);
|
||||
void Remove(AppUserReadingProfile readingProfile);
|
||||
void RemoveRange(IEnumerable<AppUserReadingProfile> readingProfiles);
|
||||
}
|
||||
|
||||
public class AppUserReadingProfileRepository(DataContext context, IMapper mapper): IAppUserReadingProfileRepository
|
||||
{
|
||||
public async Task<AppUserReadingProfile?> GetUserProfile(int userId, int profileId)
|
||||
{
|
||||
return await context.AppUserReadingProfiles
|
||||
.Where(rp => rp.AppUserId == userId && rp.Id == profileId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<AppUserReadingProfile>> GetProfilesForUser(int userId, bool skipImplicit = false)
|
||||
{
|
||||
return await context.AppUserReadingProfiles
|
||||
.Where(rp => rp.AppUserId == userId)
|
||||
.WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all Reading Profiles for the User
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IList<UserReadingProfileDto>> GetProfilesDtoForUser(int userId, bool skipImplicit = false)
|
||||
{
|
||||
return await context.AppUserReadingProfiles
|
||||
.Where(rp => rp.AppUserId == userId)
|
||||
.WhereIf(skipImplicit, rp => rp.Kind != ReadingProfileKind.Implicit)
|
||||
.ProjectTo<UserReadingProfileDto>(mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> IsProfileNameInUse(int userId, string name)
|
||||
{
|
||||
var normalizedName = name.ToNormalized();
|
||||
|
||||
return await context.AppUserReadingProfiles
|
||||
.Where(rp => rp.NormalizedName == normalizedName && rp.AppUserId == userId)
|
||||
.AnyAsync();
|
||||
}
|
||||
|
||||
public void Add(AppUserReadingProfile readingProfile)
|
||||
{
|
||||
context.AppUserReadingProfiles.Add(readingProfile);
|
||||
}
|
||||
|
||||
public void Update(AppUserReadingProfile readingProfile)
|
||||
{
|
||||
context.AppUserReadingProfiles.Update(readingProfile).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public void Remove(AppUserReadingProfile readingProfile)
|
||||
{
|
||||
context.AppUserReadingProfiles.Remove(readingProfile);
|
||||
}
|
||||
|
||||
public void RemoveRange(IEnumerable<AppUserReadingProfile> readingProfiles)
|
||||
{
|
||||
context.AppUserReadingProfiles.RemoveRange(readingProfiles);
|
||||
}
|
||||
}
|
@ -111,7 +111,7 @@ public class GenreRepository : IGenreRepository
|
||||
|
||||
/// <summary>
|
||||
/// Returns a set of Genre tags for a set of library Ids.
|
||||
/// UserId will restrict returned Genres based on user's age restriction and library access.
|
||||
/// AppUserId will restrict returned Genres based on user's age restriction and library access.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="libraryIds"></param>
|
||||
|
@ -757,7 +757,7 @@ public class UserRepository : IUserRepository
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the UserId by API Key. This does not include any extra information
|
||||
/// Fetches the AppUserId by API Key. This does not include any extra information
|
||||
/// </summary>
|
||||
/// <param name="apiKey"></param>
|
||||
/// <returns></returns>
|
||||
|
@ -33,6 +33,7 @@ public interface IUnitOfWork
|
||||
IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
|
||||
IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
||||
IEmailHistoryRepository EmailHistoryRepository { get; }
|
||||
IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
@ -74,6 +75,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper);
|
||||
ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper);
|
||||
EmailHistoryRepository = new EmailHistoryRepository(_context, _mapper);
|
||||
AppUserReadingProfileRepository = new AppUserReadingProfileRepository(_context, _mapper);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -103,6 +105,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; }
|
||||
public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; }
|
||||
public IEmailHistoryRepository EmailHistoryRepository { get; }
|
||||
public IAppUserReadingProfileRepository AppUserReadingProfileRepository { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
@ -21,6 +21,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
public ICollection<AppUserRating> Ratings { get; set; } = null!;
|
||||
public ICollection<AppUserChapterRating> ChapterRatings { get; set; } = null!;
|
||||
public AppUserPreferences UserPreferences { get; set; } = null!;
|
||||
public ICollection<AppUserReadingProfile> ReadingProfiles { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// Bookmarks associated with this User
|
||||
/// </summary>
|
||||
|
@ -1,4 +1,5 @@
|
||||
using API.Data;
|
||||
using System.Collections.Generic;
|
||||
using API.Data;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
|
143
API/Entities/AppUserReadingProfile.cs
Normal file
143
API/Entities/AppUserReadingProfile.cs
Normal file
@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.UserPreferences;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
public class AppUserReadingProfile
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
public string NormalizedName { get; set; }
|
||||
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
|
||||
public ReadingProfileKind Kind { get; set; }
|
||||
public List<int> LibraryIds { get; set; }
|
||||
public List<int> SeriesIds { get; set; }
|
||||
|
||||
#region MangaReader
|
||||
|
||||
/// <summary>
|
||||
/// Manga Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
public ReadingDirection ReadingDirection { get; set; } = ReadingDirection.LeftToRight;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How should the image be scaled to screen
|
||||
/// </summary>
|
||||
public ScalingOption ScalingOption { get; set; } = ScalingOption.Automatic;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Which side of a split image should we show first
|
||||
/// </summary>
|
||||
public PageSplitOption PageSplitOption { get; set; } = PageSplitOption.FitSplit;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How the manga reader should perform paging or reading of the file
|
||||
/// <example>
|
||||
/// Webtoon uses scrolling to page, MANGA_LR uses paging by clicking left/right side of reader, MANGA_UD uses paging
|
||||
/// by clicking top/bottom sides of reader.
|
||||
/// </example>
|
||||
/// </summary>
|
||||
public ReaderMode ReaderMode { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
|
||||
/// </summary>
|
||||
public bool AutoCloseMenu { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
|
||||
/// </summary>
|
||||
public bool ShowScreenHints { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Emulate a book by applying a shadow effect on the pages
|
||||
/// </summary>
|
||||
public bool EmulateBook { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: How many pages to display in the reader at once
|
||||
/// </summary>
|
||||
public LayoutMode LayoutMode { get; set; } = LayoutMode.Single;
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Background color of the reader
|
||||
/// </summary>
|
||||
public string BackgroundColor { get; set; } = "#000000";
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Should swiping trigger pagination
|
||||
/// </summary>
|
||||
public bool SwipeToPaginate { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Allow Automatic Webtoon detection
|
||||
/// </summary>
|
||||
public bool AllowAutomaticWebtoonReaderDetection { get; set; }
|
||||
/// <summary>
|
||||
/// Manga Reader Option: Optional fixed width override
|
||||
/// </summary>
|
||||
public int? WidthOverride { get; set; } = null;
|
||||
|
||||
#endregion
|
||||
|
||||
#region EpubReader
|
||||
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override extra Margin
|
||||
/// </summary>
|
||||
public int BookReaderMargin { get; set; } = 15;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override line-height
|
||||
/// </summary>
|
||||
public int BookReaderLineSpacing { get; set; } = 100;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Override font size
|
||||
/// </summary>
|
||||
public int BookReaderFontSize { get; set; } = 100;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
|
||||
/// </summary>
|
||||
public string BookReaderFontFamily { get; set; } = "default";
|
||||
/// <summary>
|
||||
/// Book Reader Option: Allows tapping on side of screens to paginate
|
||||
/// </summary>
|
||||
public bool BookReaderTapToPaginate { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Book Reader Option: What direction should the next/prev page buttons go
|
||||
/// </summary>
|
||||
public ReadingDirection BookReaderReadingDirection { get; set; } = ReadingDirection.LeftToRight;
|
||||
/// <summary>
|
||||
/// Book Reader Option: Defines the writing styles vertical/horizontal
|
||||
/// </summary>
|
||||
public WritingStyle BookReaderWritingStyle { get; set; } = WritingStyle.Horizontal;
|
||||
/// <summary>
|
||||
/// Book Reader Option: The color theme to decorate the book contents
|
||||
/// </summary>
|
||||
/// <remarks>Should default to Dark</remarks>
|
||||
public string BookThemeName { get; set; } = "Dark";
|
||||
/// <summary>
|
||||
/// Book Reader Option: The way a page from a book is rendered. Default is as book dictates, 1 column is fit to height,
|
||||
/// 2 column is fit to height, 2 columns
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to Default</remarks>
|
||||
public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default;
|
||||
/// <summary>
|
||||
/// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this.
|
||||
/// </summary>
|
||||
/// <remarks>Defaults to false</remarks>
|
||||
public bool BookReaderImmersiveMode { get; set; } = false;
|
||||
#endregion
|
||||
|
||||
#region PdfReader
|
||||
|
||||
/// <summary>
|
||||
/// PDF Reader: Theme of the Reader
|
||||
/// </summary>
|
||||
public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
|
||||
/// <summary>
|
||||
/// PDF Reader: Scroll mode of the reader
|
||||
/// </summary>
|
||||
public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
|
||||
/// <summary>
|
||||
/// PDF Reader: Spread Mode of the reader
|
||||
/// </summary>
|
||||
public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
|
||||
|
||||
|
||||
#endregion
|
||||
}
|
17
API/Entities/Enums/ReadingProfileKind.cs
Normal file
17
API/Entities/Enums/ReadingProfileKind.cs
Normal file
@ -0,0 +1,17 @@
|
||||
namespace API.Entities.Enums;
|
||||
|
||||
public enum ReadingProfileKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate by Kavita when registering a user, this is your default profile
|
||||
/// </summary>
|
||||
Default,
|
||||
/// <summary>
|
||||
/// Created by the user in the UI or via the API
|
||||
/// </summary>
|
||||
User,
|
||||
/// <summary>
|
||||
/// Automatically generated by Kavita to track changes made in the readers. Can be converted to a User Reading Profile.
|
||||
/// </summary>
|
||||
Implicit
|
||||
}
|
@ -54,6 +54,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<IStreamService, StreamService>();
|
||||
services.AddScoped<IRatingService, RatingService>();
|
||||
services.AddScoped<IPersonService, PersonService>();
|
||||
services.AddScoped<IReadingProfileService, ReadingProfileService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
|
@ -275,13 +275,12 @@ public class AutoMapperProfiles : Profile
|
||||
CreateMap<AppUserPreferences, UserPreferencesDto>()
|
||||
.ForMember(dest => dest.Theme,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.Theme))
|
||||
opt.MapFrom(src => src.Theme));
|
||||
|
||||
CreateMap<AppUserReadingProfile, UserReadingProfileDto>()
|
||||
.ForMember(dest => dest.BookReaderThemeName,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.BookThemeName))
|
||||
.ForMember(dest => dest.BookReaderLayoutMode,
|
||||
opt =>
|
||||
opt.MapFrom(src => src.BookReaderLayoutMode));
|
||||
opt.MapFrom(src => src.BookThemeName));
|
||||
|
||||
|
||||
CreateMap<AppUserBookmark, BookmarkDto>();
|
||||
|
@ -21,7 +21,7 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
|
||||
ApiKey = HashUtil.ApiKey(),
|
||||
UserPreferences = new AppUserPreferences
|
||||
{
|
||||
Theme = theme ?? Seed.DefaultThemes.First()
|
||||
Theme = theme ?? Seed.DefaultThemes.First(),
|
||||
},
|
||||
ReadingLists = new List<ReadingList>(),
|
||||
Bookmarks = new List<AppUserBookmark>(),
|
||||
@ -31,7 +31,8 @@ public class AppUserBuilder : IEntityBuilder<AppUser>
|
||||
Devices = new List<Device>(),
|
||||
Id = 0,
|
||||
DashboardStreams = new List<AppUserDashboardStream>(),
|
||||
SideNavStreams = new List<AppUserSideNavStream>()
|
||||
SideNavStreams = new List<AppUserSideNavStream>(),
|
||||
ReadingProfiles = [],
|
||||
};
|
||||
}
|
||||
|
||||
|
54
API/Helpers/Builders/AppUserReadingProfileBuilder.cs
Normal file
54
API/Helpers/Builders/AppUserReadingProfileBuilder.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class AppUserReadingProfileBuilder
|
||||
{
|
||||
private readonly AppUserReadingProfile _profile;
|
||||
|
||||
public AppUserReadingProfile Build() => _profile;
|
||||
|
||||
/// <summary>
|
||||
/// The profile's kind will be <see cref="ReadingProfileKind.User"/> unless overwritten with <see cref="WithKind"/>
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
public AppUserReadingProfileBuilder(int userId)
|
||||
{
|
||||
_profile = new AppUserReadingProfile
|
||||
{
|
||||
AppUserId = userId,
|
||||
Kind = ReadingProfileKind.User,
|
||||
SeriesIds = [],
|
||||
LibraryIds = []
|
||||
};
|
||||
}
|
||||
|
||||
public AppUserReadingProfileBuilder WithSeries(Series series)
|
||||
{
|
||||
_profile.SeriesIds.Add(series.Id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AppUserReadingProfileBuilder WithLibrary(Library library)
|
||||
{
|
||||
_profile.LibraryIds.Add(library.Id);
|
||||
return this;
|
||||
}
|
||||
|
||||
public AppUserReadingProfileBuilder WithKind(ReadingProfileKind kind)
|
||||
{
|
||||
_profile.Kind = kind;
|
||||
return this;
|
||||
}
|
||||
|
||||
public AppUserReadingProfileBuilder WithName(string name)
|
||||
{
|
||||
_profile.Name = name;
|
||||
_profile.NormalizedName = name.ToNormalized();
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -230,6 +230,8 @@
|
||||
"scan-libraries": "Scan Libraries",
|
||||
"kavita+-data-refresh": "Kavita+ Data Refresh",
|
||||
"backup": "Backup",
|
||||
"update-yearly-stats": "Update Yearly Stats"
|
||||
"update-yearly-stats": "Update Yearly Stats",
|
||||
|
||||
"generated-reading-profile-name": "Generated from {0}"
|
||||
|
||||
}
|
||||
|
@ -261,7 +261,7 @@ public class ScrobblingService : IScrobblingService
|
||||
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata | SeriesIncludes.Library);
|
||||
if (series == null) throw new KavitaException(await _localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling review event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
_logger.LogInformation("Processing Scrobbling review event for {AppUserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
|
||||
if (IsAniListReviewValid(reviewTitle, reviewBody))
|
||||
@ -297,7 +297,7 @@ public class ScrobblingService : IScrobblingService
|
||||
};
|
||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||
await _unitOfWork.CommitAsync();
|
||||
_logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
||||
_logger.LogDebug("Added Scrobbling Review update on {SeriesName} with Userid {AppUserId} ", series.Name, userId);
|
||||
}
|
||||
|
||||
private static bool IsAniListReviewValid(string reviewTitle, string reviewBody)
|
||||
@ -317,7 +317,7 @@ public class ScrobblingService : IScrobblingService
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling rating event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
_logger.LogInformation("Processing Scrobbling rating event for {AppUserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
|
||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||
@ -346,7 +346,7 @@ public class ScrobblingService : IScrobblingService
|
||||
};
|
||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||
await _unitOfWork.CommitAsync();
|
||||
_logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {UserId}", series.Name, userId);
|
||||
_logger.LogDebug("Added Scrobbling Rating update on {SeriesName} with Userid {AppUserId}", series.Name, userId);
|
||||
}
|
||||
|
||||
public static long? GetMalId(Series series)
|
||||
@ -371,7 +371,7 @@ public class ScrobblingService : IScrobblingService
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
|
||||
|
||||
_logger.LogInformation("Processing Scrobbling reading event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
_logger.LogInformation("Processing Scrobbling reading event for {AppUserId} on {SeriesName}", userId, series.Name);
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
|
||||
var existingEvt = await _unitOfWork.ScrobbleRepository.GetEvent(userId, series.Id,
|
||||
@ -418,7 +418,7 @@ public class ScrobblingService : IScrobblingService
|
||||
|
||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||
await _unitOfWork.CommitAsync();
|
||||
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {UserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId);
|
||||
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} - Volume: {VolumeNumber} Chapter: {ChapterNumber} for User: {AppUserId}", series.Name, evt.VolumeNumber, evt.ChapterNumber, userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -437,7 +437,7 @@ public class ScrobblingService : IScrobblingService
|
||||
if (user == null || !user.UserPreferences.AniListScrobblingEnabled) return;
|
||||
|
||||
if (await CheckIfCannotScrobble(userId, seriesId, series)) return;
|
||||
_logger.LogInformation("Processing Scrobbling want-to-read event for {UserId} on {SeriesName}", userId, series.Name);
|
||||
_logger.LogInformation("Processing Scrobbling want-to-read event for {AppUserId} on {SeriesName}", userId, series.Name);
|
||||
|
||||
// Get existing events for this series/user
|
||||
var existingEvents = (await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId))
|
||||
@ -463,7 +463,7 @@ public class ScrobblingService : IScrobblingService
|
||||
|
||||
_unitOfWork.ScrobbleRepository.Attach(evt);
|
||||
await _unitOfWork.CommitAsync();
|
||||
_logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {UserId} ", series.Name, userId);
|
||||
_logger.LogDebug("Added Scrobbling WantToRead update on {SeriesName} with Userid {AppUserId} ", series.Name, userId);
|
||||
}
|
||||
|
||||
private async Task<bool> CheckIfCannotScrobble(int userId, int seriesId, Series series)
|
||||
@ -471,7 +471,7 @@ public class ScrobblingService : IScrobblingService
|
||||
if (series.DontMatch) return true;
|
||||
if (await _unitOfWork.UserRepository.HasHoldOnSeries(userId, seriesId))
|
||||
{
|
||||
_logger.LogInformation("Series {SeriesName} is on UserId {UserId}'s hold list. Not scrobbling", series.Name,
|
||||
_logger.LogInformation("Series {SeriesName} is on AppUserId {AppUserId}'s hold list. Not scrobbling", series.Name,
|
||||
userId);
|
||||
return true;
|
||||
}
|
||||
@ -750,7 +750,7 @@ public class ScrobblingService : IScrobblingService
|
||||
/// <param name="seriesId"></param>
|
||||
public async Task ClearEventsForSeries(int userId, int seriesId)
|
||||
{
|
||||
_logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {UserId} as Series is now on hold list", seriesId, userId);
|
||||
_logger.LogInformation("Clearing Pre-existing Scrobble events for Series {SeriesId} by User {AppUserId} as Series is now on hold list", seriesId, userId);
|
||||
var events = await _unitOfWork.ScrobbleRepository.GetUserEventsForSeries(userId, seriesId);
|
||||
foreach (var scrobble in events)
|
||||
{
|
||||
@ -1109,7 +1109,7 @@ public class ScrobblingService : IScrobblingService
|
||||
{
|
||||
if (ex.Message.Contains("Access token is invalid"))
|
||||
{
|
||||
_logger.LogCritical(ex, "Access Token for UserId: {UserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id);
|
||||
_logger.LogCritical(ex, "Access Token for AppUserId: {AppUserId} needs to be regenerated/renewed to continue scrobbling", evt.AppUser.Id);
|
||||
evt.IsErrored = true;
|
||||
evt.ErrorDetails = AccessTokenErrorMessage;
|
||||
_unitOfWork.ScrobbleRepository.Update(evt);
|
||||
|
453
API/Services/ReadingProfileService.cs
Normal file
453
API/Services/ReadingProfileService.cs
Normal file
@ -0,0 +1,453 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
|
||||
namespace API.Services;
|
||||
#nullable enable
|
||||
|
||||
public interface IReadingProfileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the ReadingProfile that should be applied to the given series, walks up the tree.
|
||||
/// Series (Implicit) -> Series (User) -> Library (User) -> Default
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="skipImplicit"></param>
|
||||
/// <returns></returns>
|
||||
Task<UserReadingProfileDto> GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new reading profile for a user. Name must be unique per user
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
Task<UserReadingProfileDto> CreateReadingProfile(int userId, UserReadingProfileDto dto);
|
||||
Task<UserReadingProfileDto> PromoteImplicitProfile(int userId, int profileId);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the implicit reading profile for a series, creates one if none exists
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
Task<UserReadingProfileDto> UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the non-implicit reading profile for the given series, and removes implicit profiles
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
Task<UserReadingProfileDto> UpdateParent(int userId, int seriesId, UserReadingProfileDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Updates a given reading profile for a user
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Does not update connected series and libraries</remarks>
|
||||
Task<UserReadingProfileDto> UpdateReadingProfile(int userId, UserReadingProfileDto dto);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a given profile for a user
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="UnauthorizedAccessException"></exception>
|
||||
/// <exception cref="KavitaException">The default profile for the user cannot be deleted</exception>
|
||||
Task DeleteReadingProfile(int userId, int profileId);
|
||||
|
||||
/// <summary>
|
||||
/// Binds the reading profile to the series, and remove the implicit RP from the series if it exists
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
Task AddProfileToSeries(int userId, int profileId, int seriesId);
|
||||
/// <summary>
|
||||
/// Binds the reading profile to many series, and remove the implicit RP from the series if it exists
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <param name="seriesIds"></param>
|
||||
/// <returns></returns>
|
||||
Task BulkAddProfileToSeries(int userId, int profileId, IList<int> seriesIds);
|
||||
/// <summary>
|
||||
/// Remove all reading profiles bound to the series
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <returns></returns>
|
||||
Task ClearSeriesProfile(int userId, int seriesId);
|
||||
|
||||
/// <summary>
|
||||
/// Bind the reading profile to the library
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
Task AddProfileToLibrary(int userId, int profileId, int libraryId);
|
||||
/// <summary>
|
||||
/// Remove the reading profile bound to the library, if it exists
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
Task ClearLibraryProfile(int userId, int libraryId);
|
||||
/// <summary>
|
||||
/// Returns the bound Reading Profile to a Library
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="libraryId"></param>
|
||||
/// <returns></returns>
|
||||
Task<UserReadingProfileDto?> GetReadingProfileDtoForLibrary(int userId, int libraryId);
|
||||
}
|
||||
|
||||
public class ReadingProfileService(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper): IReadingProfileService
|
||||
{
|
||||
/// <summary>
|
||||
/// Tries to resolve the Reading Profile for a given Series. Will first check (optionally) Implicit profiles, then check for a bound Series profile, then a bound
|
||||
/// Library profile, then default to the default profile.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="seriesId"></param>
|
||||
/// <param name="skipImplicit"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
public async Task<AppUserReadingProfile> GetReadingProfileForSeries(int userId, int seriesId, bool skipImplicit = false)
|
||||
{
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, skipImplicit);
|
||||
|
||||
// If there is an implicit, send back
|
||||
var implicitProfile =
|
||||
profiles.FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind == ReadingProfileKind.Implicit);
|
||||
if (implicitProfile != null) return implicitProfile;
|
||||
|
||||
// Next check for a bound Series profile
|
||||
var seriesProfile = profiles
|
||||
.FirstOrDefault(p => p.SeriesIds.Contains(seriesId) && p.Kind != ReadingProfileKind.Implicit);
|
||||
if (seriesProfile != null) return seriesProfile;
|
||||
|
||||
// Check for a library bound profile
|
||||
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
if (series == null) throw new KavitaException(await localizationService.Translate(userId, "series-doesnt-exist"));
|
||||
|
||||
var libraryProfile = profiles
|
||||
.FirstOrDefault(p => p.LibraryIds.Contains(series.LibraryId) && p.Kind != ReadingProfileKind.Implicit);
|
||||
if (libraryProfile != null) return libraryProfile;
|
||||
|
||||
// Fallback to the default profile
|
||||
return profiles.First(p => p.Kind == ReadingProfileKind.Default);
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto> GetReadingProfileDtoForSeries(int userId, int seriesId, bool skipImplicit = false)
|
||||
{
|
||||
return mapper.Map<UserReadingProfileDto>(await GetReadingProfileForSeries(userId, seriesId, skipImplicit));
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto> UpdateParent(int userId, int seriesId, UserReadingProfileDto dto)
|
||||
{
|
||||
var parentProfile = await GetReadingProfileForSeries(userId, seriesId, true);
|
||||
|
||||
UpdateReaderProfileFields(parentProfile, dto, false);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(parentProfile);
|
||||
|
||||
// Remove the implicit profile when we UpdateParent (from reader) as it is implied that we are already bound with a non-implicit profile
|
||||
await DeleteImplicateReadingProfilesForSeries(userId, [seriesId]);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return mapper.Map<UserReadingProfileDto>(parentProfile);
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto> UpdateReadingProfile(int userId, UserReadingProfileDto dto)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, dto.Id);
|
||||
if (profile == null) throw new KavitaException("profile-does-not-exist");
|
||||
|
||||
UpdateReaderProfileFields(profile, dto);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profile);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return mapper.Map<UserReadingProfileDto>(profile);
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto> CreateReadingProfile(int userId, UserReadingProfileDto dto)
|
||||
{
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
if (await unitOfWork.AppUserReadingProfileRepository.IsProfileNameInUse(userId, dto.Name)) throw new KavitaException("name-already-in-use");
|
||||
|
||||
var newProfile = new AppUserReadingProfileBuilder(user.Id).Build();
|
||||
UpdateReaderProfileFields(newProfile, dto);
|
||||
|
||||
unitOfWork.AppUserReadingProfileRepository.Add(newProfile);
|
||||
user.ReadingProfiles.Add(newProfile);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return mapper.Map<UserReadingProfileDto>(newProfile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Promotes the implicit profile to a user profile. Removes the series from other profiles.
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="profileId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<UserReadingProfileDto> PromoteImplicitProfile(int userId, int profileId)
|
||||
{
|
||||
// Get all the user's profiles including the implicit
|
||||
var allUserProfiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, false);
|
||||
var profileToPromote = allUserProfiles.First(r => r.Id == profileId);
|
||||
var seriesId = profileToPromote.SeriesIds[0]; // An Implicit series can only be bound to 1 Series
|
||||
|
||||
// Check if there are any reading profiles (Series) already bound to the series
|
||||
var existingSeriesProfile = allUserProfiles.FirstOrDefault(r => r.SeriesIds.Contains(seriesId) && r.Kind == ReadingProfileKind.User);
|
||||
if (existingSeriesProfile != null)
|
||||
{
|
||||
existingSeriesProfile.SeriesIds.Remove(seriesId);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(existingSeriesProfile);
|
||||
}
|
||||
|
||||
// Convert the implicit profile into a proper Series
|
||||
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
|
||||
if (series == null) throw new KavitaException("series-doesnt-exist"); // Shouldn't happen
|
||||
|
||||
profileToPromote.Kind = ReadingProfileKind.User;
|
||||
profileToPromote.Name = await localizationService.Translate(userId, "generated-reading-profile-name", series.Name);
|
||||
profileToPromote.Name = EnsureUniqueProfileName(allUserProfiles, profileToPromote.Name);
|
||||
profileToPromote.NormalizedName = profileToPromote.Name.ToNormalized();
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profileToPromote);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return mapper.Map<UserReadingProfileDto>(profileToPromote);
|
||||
}
|
||||
|
||||
private static string EnsureUniqueProfileName(IList<AppUserReadingProfile> allUserProfiles, string name)
|
||||
{
|
||||
var counter = 1;
|
||||
var newName = name;
|
||||
while (allUserProfiles.Any(p => p.Name == newName))
|
||||
{
|
||||
newName = $"{name} ({counter})";
|
||||
counter++;
|
||||
}
|
||||
|
||||
return newName;
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto> UpdateImplicitReadingProfile(int userId, int seriesId, UserReadingProfileDto dto)
|
||||
{
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId);
|
||||
var existingProfile = profiles.FirstOrDefault(rp => rp.Kind == ReadingProfileKind.Implicit && rp.SeriesIds.Contains(seriesId));
|
||||
|
||||
// Series already had an implicit profile, update it
|
||||
if (existingProfile is {Kind: ReadingProfileKind.Implicit})
|
||||
{
|
||||
UpdateReaderProfileFields(existingProfile, dto, false);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(existingProfile);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return mapper.Map<UserReadingProfileDto>(existingProfile);
|
||||
}
|
||||
|
||||
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId) ?? throw new KeyNotFoundException();
|
||||
var newProfile = new AppUserReadingProfileBuilder(userId)
|
||||
.WithSeries(series)
|
||||
.WithKind(ReadingProfileKind.Implicit)
|
||||
.Build();
|
||||
|
||||
// Set name to something fitting for debugging if needed
|
||||
UpdateReaderProfileFields(newProfile, dto, false);
|
||||
newProfile.Name = $"Implicit Profile for {seriesId}";
|
||||
newProfile.NormalizedName = newProfile.Name.ToNormalized();
|
||||
|
||||
user.ReadingProfiles.Add(newProfile);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return mapper.Map<UserReadingProfileDto>(newProfile);
|
||||
}
|
||||
|
||||
public async Task DeleteReadingProfile(int userId, int profileId)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId);
|
||||
if (profile == null) throw new KavitaException("profile-doesnt-exist");
|
||||
|
||||
if (profile.Kind == ReadingProfileKind.Default) throw new KavitaException("cant-delete-default-profile");
|
||||
|
||||
unitOfWork.AppUserReadingProfileRepository.Remove(profile);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task AddProfileToSeries(int userId, int profileId, int seriesId)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId);
|
||||
if (profile == null) throw new KavitaException("profile-doesnt-exist");
|
||||
|
||||
await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []);
|
||||
|
||||
profile.SeriesIds.Add(seriesId);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profile);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task BulkAddProfileToSeries(int userId, int profileId, IList<int> seriesIds)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId);
|
||||
if (profile == null) throw new KavitaException("profile-doesnt-exist");
|
||||
|
||||
await DeleteImplicitAndRemoveFromUserProfiles(userId, seriesIds, []);
|
||||
|
||||
profile.SeriesIds.AddRange(seriesIds.Except(profile.SeriesIds));
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profile);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task ClearSeriesProfile(int userId, int seriesId)
|
||||
{
|
||||
await DeleteImplicitAndRemoveFromUserProfiles(userId, [seriesId], []);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task AddProfileToLibrary(int userId, int profileId, int libraryId)
|
||||
{
|
||||
var profile = await unitOfWork.AppUserReadingProfileRepository.GetUserProfile(userId, profileId);
|
||||
if (profile == null) throw new KavitaException("profile-doesnt-exist");
|
||||
|
||||
await DeleteImplicitAndRemoveFromUserProfiles(userId, [], [libraryId]);
|
||||
|
||||
profile.LibraryIds.Add(libraryId);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profile);
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task ClearLibraryProfile(int userId, int libraryId)
|
||||
{
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId);
|
||||
var libraryProfile = profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId));
|
||||
if (libraryProfile != null)
|
||||
{
|
||||
libraryProfile.LibraryIds.Remove(libraryId);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(libraryProfile);
|
||||
}
|
||||
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<UserReadingProfileDto?> GetReadingProfileDtoForLibrary(int userId, int libraryId)
|
||||
{
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId, true);
|
||||
return mapper.Map<UserReadingProfileDto>(profiles.FirstOrDefault(p => p.LibraryIds.Contains(libraryId)));
|
||||
}
|
||||
|
||||
private async Task DeleteImplicitAndRemoveFromUserProfiles(int userId, IList<int> seriesIds, IList<int> libraryIds)
|
||||
{
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId);
|
||||
var implicitProfiles = profiles
|
||||
.Where(rp => rp.SeriesIds.Intersect(seriesIds).Any())
|
||||
.Where(rp => rp.Kind == ReadingProfileKind.Implicit)
|
||||
.ToList();
|
||||
unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles);
|
||||
|
||||
var nonImplicitProfiles = profiles
|
||||
.Where(rp => rp.SeriesIds.Intersect(seriesIds).Any() || rp.LibraryIds.Intersect(libraryIds).Any())
|
||||
.Where(rp => rp.Kind != ReadingProfileKind.Implicit);
|
||||
|
||||
foreach (var profile in nonImplicitProfiles)
|
||||
{
|
||||
profile.SeriesIds.RemoveAll(seriesIds.Contains);
|
||||
profile.LibraryIds.RemoveAll(libraryIds.Contains);
|
||||
unitOfWork.AppUserReadingProfileRepository.Update(profile);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteImplicateReadingProfilesForSeries(int userId, IList<int> seriesIds)
|
||||
{
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId);
|
||||
var implicitProfiles = profiles
|
||||
.Where(rp => rp.SeriesIds.Intersect(seriesIds).Any())
|
||||
.Where(rp => rp.Kind == ReadingProfileKind.Implicit)
|
||||
.ToList();
|
||||
unitOfWork.AppUserReadingProfileRepository.RemoveRange(implicitProfiles);
|
||||
}
|
||||
|
||||
private async Task RemoveSeriesFromUserProfiles(int userId, IList<int> seriesIds)
|
||||
{
|
||||
var profiles = await unitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(userId);
|
||||
var userProfiles = profiles
|
||||
.Where(rp => rp.SeriesIds.Intersect(seriesIds).Any())
|
||||
.Where(rp => rp.Kind == ReadingProfileKind.User)
|
||||
.ToList();
|
||||
|
||||
unitOfWork.AppUserReadingProfileRepository.RemoveRange(userProfiles);
|
||||
}
|
||||
|
||||
public static void UpdateReaderProfileFields(AppUserReadingProfile existingProfile, UserReadingProfileDto dto, bool updateName = true)
|
||||
{
|
||||
if (updateName && !string.IsNullOrEmpty(dto.Name) && existingProfile.NormalizedName != dto.Name.ToNormalized())
|
||||
{
|
||||
existingProfile.Name = dto.Name;
|
||||
existingProfile.NormalizedName = dto.Name.ToNormalized();
|
||||
}
|
||||
|
||||
// Manga Reader
|
||||
existingProfile.ReadingDirection = dto.ReadingDirection;
|
||||
existingProfile.ScalingOption = dto.ScalingOption;
|
||||
existingProfile.PageSplitOption = dto.PageSplitOption;
|
||||
existingProfile.ReaderMode = dto.ReaderMode;
|
||||
existingProfile.AutoCloseMenu = dto.AutoCloseMenu;
|
||||
existingProfile.ShowScreenHints = dto.ShowScreenHints;
|
||||
existingProfile.EmulateBook = dto.EmulateBook;
|
||||
existingProfile.LayoutMode = dto.LayoutMode;
|
||||
existingProfile.BackgroundColor = string.IsNullOrEmpty(dto.BackgroundColor) ? "#000000" : dto.BackgroundColor;
|
||||
existingProfile.SwipeToPaginate = dto.SwipeToPaginate;
|
||||
existingProfile.AllowAutomaticWebtoonReaderDetection = dto.AllowAutomaticWebtoonReaderDetection;
|
||||
existingProfile.WidthOverride = dto.WidthOverride;
|
||||
|
||||
// Book Reader
|
||||
existingProfile.BookReaderMargin = dto.BookReaderMargin;
|
||||
existingProfile.BookReaderLineSpacing = dto.BookReaderLineSpacing;
|
||||
existingProfile.BookReaderFontSize = dto.BookReaderFontSize;
|
||||
existingProfile.BookReaderFontFamily = dto.BookReaderFontFamily;
|
||||
existingProfile.BookReaderTapToPaginate = dto.BookReaderTapToPaginate;
|
||||
existingProfile.BookReaderReadingDirection = dto.BookReaderReadingDirection;
|
||||
existingProfile.BookReaderWritingStyle = dto.BookReaderWritingStyle;
|
||||
existingProfile.BookThemeName = dto.BookReaderThemeName;
|
||||
existingProfile.BookReaderLayoutMode = dto.BookReaderLayoutMode;
|
||||
existingProfile.BookReaderImmersiveMode = dto.BookReaderImmersiveMode;
|
||||
|
||||
// PDF Reading
|
||||
existingProfile.PdfTheme = dto.PdfTheme;
|
||||
existingProfile.PdfScrollMode = dto.PdfScrollMode;
|
||||
existingProfile.PdfSpreadMode = dto.PdfSpreadMode;
|
||||
}
|
||||
}
|
@ -293,6 +293,9 @@ public class Startup
|
||||
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
|
||||
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
|
||||
|
||||
// v0.8.7
|
||||
await ManualMigrateReadingProfiles.Migrate(dataContext, logger);
|
||||
|
||||
#endregion
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
|
@ -17,7 +17,7 @@ public static class Configuration
|
||||
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||
|
||||
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
||||
? "http://localhost:5020" : "https://plus.kavitareader.com";
|
||||
? "https://plus.kavitareader.com" : "https://plus.kavitareader.com";
|
||||
public static readonly string StatsApiUrl = "https://stats.kavitareader.com";
|
||||
|
||||
public static int Port
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ThemeProvider } from "./site-theme";
|
||||
import {ThemeProvider} from "./site-theme";
|
||||
|
||||
/**
|
||||
* Theme for the the book reader contents
|
||||
* Theme for the book reader contents
|
||||
*/
|
||||
export interface BookTheme {
|
||||
name: string;
|
||||
|
@ -1,47 +1,7 @@
|
||||
import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode';
|
||||
import {BookPageLayoutMode} from '../readers/book-page-layout-mode';
|
||||
import {PageLayoutMode} from '../page-layout-mode';
|
||||
import {PageSplitOption} from './page-split-option';
|
||||
import {ReaderMode} from './reader-mode';
|
||||
import {ReadingDirection} from './reading-direction';
|
||||
import {ScalingOption} from './scaling-option';
|
||||
import {SiteTheme} from './site-theme';
|
||||
import {WritingStyle} from "./writing-style";
|
||||
import {PdfTheme} from "./pdf-theme";
|
||||
import {PdfScrollMode} from "./pdf-scroll-mode";
|
||||
import {PdfLayoutMode} from "./pdf-layout-mode";
|
||||
import {PdfSpreadMode} from "./pdf-spread-mode";
|
||||
|
||||
export interface Preferences {
|
||||
// Manga Reader
|
||||
readingDirection: ReadingDirection;
|
||||
scalingOption: ScalingOption;
|
||||
pageSplitOption: PageSplitOption;
|
||||
readerMode: ReaderMode;
|
||||
autoCloseMenu: boolean;
|
||||
layoutMode: LayoutMode;
|
||||
backgroundColor: string;
|
||||
showScreenHints: boolean;
|
||||
emulateBook: boolean;
|
||||
swipeToPaginate: boolean;
|
||||
allowAutomaticWebtoonReaderDetection: boolean;
|
||||
|
||||
// Book Reader
|
||||
bookReaderMargin: number;
|
||||
bookReaderLineSpacing: number;
|
||||
bookReaderFontSize: number;
|
||||
bookReaderFontFamily: string;
|
||||
bookReaderTapToPaginate: boolean;
|
||||
bookReaderReadingDirection: ReadingDirection;
|
||||
bookReaderWritingStyle: WritingStyle;
|
||||
bookReaderThemeName: string;
|
||||
bookReaderLayoutMode: BookPageLayoutMode;
|
||||
bookReaderImmersiveMode: boolean;
|
||||
|
||||
// PDF Reader
|
||||
pdfTheme: PdfTheme;
|
||||
pdfScrollMode: PdfScrollMode;
|
||||
pdfSpreadMode: PdfSpreadMode;
|
||||
|
||||
// Global
|
||||
theme: SiteTheme;
|
||||
@ -58,15 +18,3 @@ export interface Preferences {
|
||||
wantToReadSync: boolean;
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];
|
||||
export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}];
|
||||
export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}];
|
||||
export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}];
|
||||
export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}];
|
||||
export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover}
|
||||
export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}];
|
||||
export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}];
|
||||
export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}];
|
||||
export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}];
|
||||
export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}];
|
||||
export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}];
|
||||
|
77
UI/Web/src/app/_models/preferences/reading-profiles.ts
Normal file
77
UI/Web/src/app/_models/preferences/reading-profiles.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import {LayoutMode} from 'src/app/manga-reader/_models/layout-mode';
|
||||
import {BookPageLayoutMode} from '../readers/book-page-layout-mode';
|
||||
import {PageLayoutMode} from '../page-layout-mode';
|
||||
import {PageSplitOption} from './page-split-option';
|
||||
import {ReaderMode} from './reader-mode';
|
||||
import {ReadingDirection} from './reading-direction';
|
||||
import {ScalingOption} from './scaling-option';
|
||||
import {WritingStyle} from "./writing-style";
|
||||
import {PdfTheme} from "./pdf-theme";
|
||||
import {PdfScrollMode} from "./pdf-scroll-mode";
|
||||
import {PdfLayoutMode} from "./pdf-layout-mode";
|
||||
import {PdfSpreadMode} from "./pdf-spread-mode";
|
||||
import {Series} from "../series";
|
||||
import {Library} from "../library/library";
|
||||
|
||||
export enum ReadingProfileKind {
|
||||
Default = 0,
|
||||
User = 1,
|
||||
Implicit = 2,
|
||||
}
|
||||
|
||||
export interface ReadingProfile {
|
||||
|
||||
id: number;
|
||||
name: string;
|
||||
normalizedName: string;
|
||||
kind: ReadingProfileKind;
|
||||
|
||||
// Manga Reader
|
||||
readingDirection: ReadingDirection;
|
||||
scalingOption: ScalingOption;
|
||||
pageSplitOption: PageSplitOption;
|
||||
readerMode: ReaderMode;
|
||||
autoCloseMenu: boolean;
|
||||
layoutMode: LayoutMode;
|
||||
backgroundColor: string;
|
||||
showScreenHints: boolean;
|
||||
emulateBook: boolean;
|
||||
swipeToPaginate: boolean;
|
||||
allowAutomaticWebtoonReaderDetection: boolean;
|
||||
widthOverride?: number;
|
||||
|
||||
// Book Reader
|
||||
bookReaderMargin: number;
|
||||
bookReaderLineSpacing: number;
|
||||
bookReaderFontSize: number;
|
||||
bookReaderFontFamily: string;
|
||||
bookReaderTapToPaginate: boolean;
|
||||
bookReaderReadingDirection: ReadingDirection;
|
||||
bookReaderWritingStyle: WritingStyle;
|
||||
bookReaderThemeName: string;
|
||||
bookReaderLayoutMode: BookPageLayoutMode;
|
||||
bookReaderImmersiveMode: boolean;
|
||||
|
||||
// PDF Reader
|
||||
pdfTheme: PdfTheme;
|
||||
pdfScrollMode: PdfScrollMode;
|
||||
pdfSpreadMode: PdfSpreadMode;
|
||||
|
||||
// relations
|
||||
seriesIds: number[];
|
||||
libraryIds: number[];
|
||||
|
||||
}
|
||||
|
||||
export const readingDirections = [{text: 'left-to-right', value: ReadingDirection.LeftToRight}, {text: 'right-to-left', value: ReadingDirection.RightToLeft}];
|
||||
export const bookWritingStyles = [{text: 'horizontal', value: WritingStyle.Horizontal}, {text: 'vertical', value: WritingStyle.Vertical}];
|
||||
export const scalingOptions = [{text: 'automatic', value: ScalingOption.Automatic}, {text: 'fit-to-height', value: ScalingOption.FitToHeight}, {text: 'fit-to-width', value: ScalingOption.FitToWidth}, {text: 'original', value: ScalingOption.Original}];
|
||||
export const pageSplitOptions = [{text: 'fit-to-screen', value: PageSplitOption.FitSplit}, {text: 'right-to-left', value: PageSplitOption.SplitRightToLeft}, {text: 'left-to-right', value: PageSplitOption.SplitLeftToRight}, {text: 'no-split', value: PageSplitOption.NoSplit}];
|
||||
export const readingModes = [{text: 'left-to-right', value: ReaderMode.LeftRight}, {text: 'up-to-down', value: ReaderMode.UpDown}, {text: 'webtoon', value: ReaderMode.Webtoon}];
|
||||
export const layoutModes = [{text: 'single', value: LayoutMode.Single}, {text: 'double', value: LayoutMode.Double}, {text: 'double-manga', value: LayoutMode.DoubleReversed}]; // TODO: Build this, {text: 'Double (No Cover)', value: LayoutMode.DoubleNoCover}
|
||||
export const bookLayoutModes = [{text: 'scroll', value: BookPageLayoutMode.Default}, {text: '1-column', value: BookPageLayoutMode.Column1}, {text: '2-column', value: BookPageLayoutMode.Column2}];
|
||||
export const pageLayoutModes = [{text: 'cards', value: PageLayoutMode.Cards}, {text: 'list', value: PageLayoutMode.List}];
|
||||
export const pdfLayoutModes = [{text: 'pdf-multiple', value: PdfLayoutMode.Multiple}, {text: 'pdf-book', value: PdfLayoutMode.Book}];
|
||||
export const pdfScrollModes = [{text: 'pdf-vertical', value: PdfScrollMode.Vertical}, {text: 'pdf-horizontal', value: PdfScrollMode.Horizontal}, {text: 'pdf-page', value: PdfScrollMode.Page}];
|
||||
export const pdfSpreadModes = [{text: 'pdf-none', value: PdfSpreadMode.None}, {text: 'pdf-odd', value: PdfSpreadMode.Odd}, {text: 'pdf-even', value: PdfSpreadMode.Even}];
|
||||
export const pdfThemes = [{text: 'pdf-light', value: PdfTheme.Light}, {text: 'pdf-dark', value: PdfTheme.Dark}];
|
18
UI/Web/src/app/_resolvers/reading-profile.resolver.ts
Normal file
18
UI/Web/src/app/_resolvers/reading-profile.resolver.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, Resolve, RouterStateSnapshot} from '@angular/router';
|
||||
import {Observable} from 'rxjs';
|
||||
import {ReadingProfileService} from "../_services/reading-profile.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingProfileResolver implements Resolve<any> {
|
||||
|
||||
constructor(private readingProfileService: ReadingProfileService) {}
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> {
|
||||
// Extract seriesId from route params or parent route
|
||||
const seriesId = route.params['seriesId'] || route.parent?.params['seriesId'];
|
||||
return this.readingProfileService.getForSeries(seriesId);
|
||||
}
|
||||
}
|
@ -1,10 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { BookReaderComponent } from '../book-reader/_components/book-reader/book-reader.component';
|
||||
import {Routes} from '@angular/router';
|
||||
import {BookReaderComponent} from '../book-reader/_components/book-reader/book-reader.component';
|
||||
import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver";
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: ':chapterId',
|
||||
component: BookReaderComponent,
|
||||
resolve: {
|
||||
readingProfile: ReadingProfileResolver
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,15 +1,22 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { MangaReaderComponent } from '../manga-reader/_components/manga-reader/manga-reader.component';
|
||||
import {Routes} from '@angular/router';
|
||||
import {MangaReaderComponent} from '../manga-reader/_components/manga-reader/manga-reader.component';
|
||||
import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver";
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: ':chapterId',
|
||||
component: MangaReaderComponent
|
||||
component: MangaReaderComponent,
|
||||
resolve: {
|
||||
readingProfile: ReadingProfileResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
// This will allow the MangaReader to have a list to use for next/prev chapters rather than natural sort order
|
||||
path: ':chapterId/list/:listId',
|
||||
component: MangaReaderComponent
|
||||
component: MangaReaderComponent,
|
||||
resolve: {
|
||||
readingProfile: ReadingProfileResolver
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { PdfReaderComponent } from '../pdf-reader/_components/pdf-reader/pdf-reader.component';
|
||||
import {Routes} from '@angular/router';
|
||||
import {PdfReaderComponent} from '../pdf-reader/_components/pdf-reader/pdf-reader.component';
|
||||
import {ReadingProfileResolver} from "../_resolvers/reading-profile.resolver";
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: ':chapterId',
|
||||
component: PdfReaderComponent,
|
||||
resolve: {
|
||||
readingProfile: ReadingProfileResolver
|
||||
}
|
||||
}
|
||||
];
|
||||
|
@ -122,6 +122,14 @@ export enum Action {
|
||||
* Merge two (or more?) entities
|
||||
*/
|
||||
Merge = 29,
|
||||
/**
|
||||
* Add to a reading profile
|
||||
*/
|
||||
SetReadingProfile = 30,
|
||||
/**
|
||||
* Remove the reading profile from the entity
|
||||
*/
|
||||
ClearReadingProfile = 31,
|
||||
}
|
||||
|
||||
/**
|
||||
@ -342,6 +350,37 @@ export class ActionFactoryService {
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'reading-profiles',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SetReadingProfile,
|
||||
title: 'set-reading-profile',
|
||||
description: 'set-reading-profile-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.ClearReadingProfile,
|
||||
title: 'clear-reading-profile',
|
||||
description: 'clear-reading-profile-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'others',
|
||||
@ -528,7 +567,7 @@ export class ActionFactoryService {
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -555,6 +594,37 @@ export class ActionFactoryService {
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'reading-profiles',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SetReadingProfile,
|
||||
title: 'set-reading-profile',
|
||||
description: 'set-reading-profile-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.ClearReadingProfile,
|
||||
title: 'clear-reading-profile',
|
||||
description: 'clear-reading-profile-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'others',
|
||||
@ -1047,7 +1117,10 @@ export class ActionFactoryService {
|
||||
|
||||
if (action.children === null || action.children?.length === 0) return;
|
||||
|
||||
action.children?.forEach((childAction) => {
|
||||
// Ensure action children are a copy of the parent (since parent does a shallow mapping)
|
||||
action.children = action.children.map(d => { return {...d}; });
|
||||
|
||||
action.children.forEach((childAction) => {
|
||||
this.applyCallback(childAction, callback, shouldRenderFunc);
|
||||
});
|
||||
}
|
||||
@ -1055,10 +1128,13 @@ export class ActionFactoryService {
|
||||
public applyCallbackToList(list: Array<ActionItem<any>>,
|
||||
callback: ActionCallback<any>,
|
||||
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
|
||||
// Create a clone of the list to ensure we aren't affecting the default state
|
||||
const actions = list.map((a) => {
|
||||
return { ...a };
|
||||
});
|
||||
|
||||
actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,9 @@ import {ChapterService} from "./chapter.service";
|
||||
import {VolumeService} from "./volume.service";
|
||||
import {DefaultModalOptions} from "../_models/default-modal-options";
|
||||
import {MatchSeriesModalComponent} from "../_single-module/match-series-modal/match-series-modal.component";
|
||||
import {
|
||||
BulkSetReadingProfileModalComponent
|
||||
} from "../cards/_modals/bulk-set-reading-profile-modal/bulk-set-reading-profile-modal.component";
|
||||
|
||||
|
||||
export type LibraryActionCallback = (library: Partial<Library>) => void;
|
||||
@ -813,4 +816,56 @@ export class ActionService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the reading profile for multiple series
|
||||
* @param series
|
||||
* @param callback
|
||||
*/
|
||||
setReadingProfileForMultiple(series: Array<Series>, callback?: BooleanActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
|
||||
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
|
||||
this.readingListModalRef.componentInstance.seriesIds = series.map(s => s.id)
|
||||
this.readingListModalRef.componentInstance.title = ""
|
||||
|
||||
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback(true);
|
||||
}
|
||||
});
|
||||
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the reading profile for multiple series
|
||||
* @param library
|
||||
* @param callback
|
||||
*/
|
||||
setReadingProfileForLibrary(library: Library, callback?: BooleanActionCallback) {
|
||||
if (this.readingListModalRef != null) { return; }
|
||||
|
||||
this.readingListModalRef = this.modalService.open(BulkSetReadingProfileModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
|
||||
this.readingListModalRef.componentInstance.libraryId = library.id;
|
||||
this.readingListModalRef.componentInstance.title = ""
|
||||
|
||||
this.readingListModalRef.closed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback(true);
|
||||
}
|
||||
});
|
||||
this.readingListModalRef.dismissed.pipe(take(1)).subscribe(() => {
|
||||
this.readingListModalRef = null;
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
70
UI/Web/src/app/_services/reading-profile.service.ts
Normal file
70
UI/Web/src/app/_services/reading-profile.service.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {ReadingProfile} from "../_models/preferences/reading-profiles";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ReadingProfileService {
|
||||
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
getForSeries(seriesId: number, skipImplicit: boolean = false) {
|
||||
return this.httpClient.get<ReadingProfile>(this.baseUrl + `reading-profile/${seriesId}?skipImplicit=${skipImplicit}`);
|
||||
}
|
||||
|
||||
getForLibrary(libraryId: number) {
|
||||
return this.httpClient.get<ReadingProfile | null>(this.baseUrl + `reading-profile/library?libraryId=${libraryId}`);
|
||||
}
|
||||
|
||||
updateProfile(profile: ReadingProfile) {
|
||||
return this.httpClient.post<ReadingProfile>(this.baseUrl + 'reading-profile', profile);
|
||||
}
|
||||
|
||||
updateParentProfile(seriesId: number, profile: ReadingProfile) {
|
||||
return this.httpClient.post<ReadingProfile>(this.baseUrl + `reading-profile/update-parent?seriesId=${seriesId}`, profile);
|
||||
}
|
||||
|
||||
createProfile(profile: ReadingProfile) {
|
||||
return this.httpClient.post<ReadingProfile>(this.baseUrl + 'reading-profile/create', profile);
|
||||
}
|
||||
|
||||
promoteProfile(profileId: number) {
|
||||
return this.httpClient.post<ReadingProfile>(this.baseUrl + "reading-profile/promote?profileId=" + profileId, {});
|
||||
}
|
||||
|
||||
updateImplicit(profile: ReadingProfile, seriesId: number) {
|
||||
return this.httpClient.post<ReadingProfile>(this.baseUrl + "reading-profile/series?seriesId="+seriesId, profile);
|
||||
}
|
||||
|
||||
getAllProfiles() {
|
||||
return this.httpClient.get<ReadingProfile[]>(this.baseUrl + 'reading-profile/all');
|
||||
}
|
||||
|
||||
delete(id: number) {
|
||||
return this.httpClient.delete(this.baseUrl + `reading-profile?profileId=${id}`);
|
||||
}
|
||||
|
||||
addToSeries(id: number, seriesId: number) {
|
||||
return this.httpClient.post(this.baseUrl + `reading-profile/series/${seriesId}?profileId=${id}`, {});
|
||||
}
|
||||
|
||||
clearSeriesProfiles(seriesId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + `reading-profile/series/${seriesId}`, {});
|
||||
}
|
||||
|
||||
addToLibrary(id: number, libraryId: number) {
|
||||
return this.httpClient.post(this.baseUrl + `reading-profile/library/${libraryId}?profileId=${id}`, {});
|
||||
}
|
||||
|
||||
clearLibraryProfiles(libraryId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + `reading-profile/library/${libraryId}`, {});
|
||||
}
|
||||
|
||||
bulkAddToSeries(id: number, seriesIds: number[]) {
|
||||
return this.httpClient.post(this.baseUrl + `reading-profile/bulk?profileId=${id}`, seriesIds);
|
||||
}
|
||||
|
||||
}
|
@ -9,12 +9,12 @@ import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {allEncodeFormats} from '../_models/encode-format';
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {allCoverImageSizes, CoverImageSize} from '../_models/cover-image-size';
|
||||
import {pageLayoutModes} from "../../_models/preferences/preferences";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {EncodeFormatPipe} from "../../_pipes/encode-format.pipe";
|
||||
import {CoverImageSizePipe} from "../../_pipes/cover-image-size.pipe";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {pageLayoutModes} from "../../_models/preferences/reading-profiles";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-media-settings',
|
||||
|
@ -61,6 +61,8 @@
|
||||
<a ngbNavLink>{{t('settings-header')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<app-reader-settings
|
||||
[seriesId]="seriesId"
|
||||
[readingProfile]="readingProfile"
|
||||
(colorThemeUpdate)="updateColorTheme($event)"
|
||||
(styleUpdate)="updateReaderStyles($event)"
|
||||
(clickToPaginateChanged)="showPaginationOverlay($event)"
|
||||
|
@ -21,7 +21,6 @@ import {ToastrService} from 'ngx-toastr';
|
||||
import {forkJoin, fromEvent, merge, of} from 'rxjs';
|
||||
import {catchError, debounceTime, distinctUntilChanged, take, tap} from 'rxjs/operators';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {NavService} from 'src/app/_services/nav.service';
|
||||
import {CHAPTER_ID_DOESNT_EXIST, CHAPTER_ID_NOT_FETCHED, ReaderService} from 'src/app/_services/reader.service';
|
||||
import {SeriesService} from 'src/app/_services/series.service';
|
||||
@ -40,7 +39,6 @@ import {LibraryType} from 'src/app/_models/library/library';
|
||||
import {BookTheme} from 'src/app/_models/preferences/book-theme';
|
||||
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
|
||||
import {PageStyle, ReaderSettingsComponent} from '../reader-settings/reader-settings.component';
|
||||
import {User} from 'src/app/_models/user';
|
||||
import {ThemeService} from 'src/app/_services/theme.service';
|
||||
import {ScrollService} from 'src/app/_services/scroll.service';
|
||||
import {PAGING_DIRECTION} from 'src/app/manga-reader/_models/reader-enums';
|
||||
@ -63,6 +61,7 @@ import {
|
||||
PersonalToCEvent
|
||||
} from "../personal-table-of-contents/personal-table-of-contents.component";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
|
||||
import {ConfirmService} from "../../../shared/confirm.service";
|
||||
|
||||
|
||||
@ -121,7 +120,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
@ -148,7 +146,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
volumeId!: number;
|
||||
chapterId!: number;
|
||||
chapter!: Chapter;
|
||||
user!: User;
|
||||
readingProfile!: ReadingProfile;
|
||||
|
||||
/**
|
||||
* Reading List id. Defaults to -1.
|
||||
@ -610,7 +608,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.libraryId = parseInt(libraryId, 10);
|
||||
this.seriesId = parseInt(seriesId, 10);
|
||||
this.chapterId = parseInt(chapterId, 10);
|
||||
@ -623,19 +620,23 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.readingProfile = data['readingProfile'];
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
|
||||
if (!hasProgress) {
|
||||
this.toggleDrawer();
|
||||
this.toastr.info(translate('toasts.book-settings-info'));
|
||||
if (this.readingProfile == null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.init();
|
||||
}
|
||||
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(hasProgress => {
|
||||
if (!hasProgress) {
|
||||
this.toggleDrawer();
|
||||
this.toastr.info(translate('toasts.book-settings-info'));
|
||||
}
|
||||
});
|
||||
|
||||
this.init();
|
||||
});
|
||||
}
|
||||
|
||||
@ -670,7 +671,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.chapters = results.chapters;
|
||||
this.pageNum = results.progress.pageNum;
|
||||
this.cdRef.markForCheck();
|
||||
if (results.progress.bookScrollId) this.lastSeenScrollPartPath = results.progress.bookScrollId;
|
||||
|
||||
if (results.progress.bookScrollId) {
|
||||
this.lastSeenScrollPartPath = results.progress.bookScrollId;
|
||||
}
|
||||
|
||||
this.continuousChaptersStack.push(this.chapterId);
|
||||
|
||||
@ -771,6 +775,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.readerService.closeReader(this.readingListMode, this.readingListId);
|
||||
}
|
||||
|
||||
|
||||
sortElements(a: Element, b: Element) {
|
||||
const aTop = a.getBoundingClientRect().top;
|
||||
const bTop = b.getBoundingClientRect().top;
|
||||
@ -1049,7 +1054,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
// Virtual Paging stuff
|
||||
this.updateWidthAndHeightCalcs();
|
||||
this.updateLayoutMode(this.layoutMode || BookPageLayoutMode.Default);
|
||||
this.updateLayoutMode(this.layoutMode);
|
||||
this.addEmptyPageIfRequired();
|
||||
|
||||
// Find all the part ids and their top offset
|
||||
|
@ -1,172 +1,190 @@
|
||||
<ng-container *transloco="let t; read: 'reader-settings'">
|
||||
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
|
||||
<form [formGroup]="settingsForm">
|
||||
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
|
||||
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
|
||||
{{t('general-settings-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="control-container" >
|
||||
<div class="controls">
|
||||
<div class="mb-3">
|
||||
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
|
||||
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
|
||||
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
|
||||
</select>
|
||||
@if (readingProfile !== null) {
|
||||
<ng-container *transloco="let t; read: 'reader-settings'">
|
||||
<!-- IDEA: Move the whole reader drawer into this component and have it self contained -->
|
||||
<form [formGroup]="settingsForm">
|
||||
<div ngbAccordion [closeOthers]="false" #acc="ngbAccordion">
|
||||
<div ngbAccordionItem id="general-panel" title="General Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button ngbAccordionButton class="accordion-button" type="button" [attr.aria-expanded]="acc.isExpanded('general-panel')" aria-controls="collapseOne">
|
||||
{{t('general-settings-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="control-container" >
|
||||
<div class="controls">
|
||||
<div class="mb-3">
|
||||
<label for="library-type" class="form-label">{{t('font-family-label')}}</label>
|
||||
<select class="form-select" id="library-type" formControlName="bookReaderFontFamily">
|
||||
<option [value]="opt" *ngFor="let opt of fontOptions; let i = index">{{opt | titlecase}}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-0 controls">
|
||||
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<div class="row g-0 controls">
|
||||
<label for="fontsize" class="form-label col-6">{{t('font-size-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<i class="fa-solid fa-font" style="font-size: 12px;"></i>
|
||||
<input type="range" class="form-range ms-2 me-2" id="fontsize" min="50" max="300" step="10" formControlName="bookReaderFontSize" [ngbTooltip]="settingsForm.get('bookReaderFontSize')?.value + '%'">
|
||||
<i class="fa-solid fa-font" style="font-size: 24px;"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 controls">
|
||||
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
1x
|
||||
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
|
||||
2.5x
|
||||
<div class="row g-0 controls">
|
||||
<label for="linespacing" class="form-label col-6">{{t('line-spacing-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
{{t('line-spacing-min-label')}}
|
||||
<input type="range" class="form-range ms-2 me-2" id="linespacing" min="100" max="200" step="10" formControlName="bookReaderLineSpacing" [ngbTooltip]="settingsForm.get('bookReaderLineSpacing')?.value + '%'">
|
||||
{{t('line-spacing-max-label')}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 controls">
|
||||
<label for="margin" class="form-label col-6">{{t('margin-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<div class="row g-0 controls">
|
||||
<label for="margin" class="form-label col-6">{{t('margin-label')}}</label>
|
||||
<span class="col-6 float-end" style="display: inline-flex;">
|
||||
<i class="fa-solid fa-outdent"></i>
|
||||
<input type="range" class="form-range ms-2 me-2" id="margin" min="0" max="30" step="5" formControlName="bookReaderMargin" [ngbTooltip]="settingsForm.get('bookReaderMargin')?.value + '%'">
|
||||
<i class="fa-solid fa-indent"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 justify-content-between mt-2">
|
||||
<button (click)="resetSettings()" class="btn btn-primary col">{{t('reset-to-defaults')}}</button>
|
||||
<div class="row g-0 justify-content-between mt-2">
|
||||
<button (click)="resetSettings()" class="btn btn-primary col">{{t('reset-to-defaults')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
|
||||
{{t('reader-settings-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
|
||||
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
|
||||
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
|
||||
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
|
||||
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
|
||||
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
|
||||
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
|
||||
</button>
|
||||
|
||||
<div ngbAccordionItem id="reader-panel" title="Reader Settings" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('reader-panel')" aria-controls="collapseOne">
|
||||
{{t('reader-settings-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="readingdirection" class="form-label">{{t('reading-direction-label')}}</label>
|
||||
<button (click)="toggleReadingDirection()" class="btn btn-icon" aria-labelledby="readingdirection" title="{{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}">
|
||||
<i class="fa {{readingDirectionModel === ReadingDirection.LeftToRight ? 'fa-arrow-right' : 'fa-arrow-left'}} " aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{readingDirectionModel === ReadingDirection.LeftToRight ? t('left-to-right') : t('right-to-left')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls" style="display: flex; justify-content: space-between; align-items: center; ">
|
||||
<label for="writing-style" class="form-label">{{t('writing-style-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="writingStyleTooltip" role="button" tabindex="0" aria-describedby="writingStyle-help"></i></label>
|
||||
<ng-template #writingStyleTooltip>{{t('writing-style-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="writingStyle-help"><ng-container [ngTemplateOutlet]="writingStyleTooltip"></ng-container></span>
|
||||
<button (click)="toggleWritingStyle()" id="writing-style" class="btn btn-icon" aria-labelledby="writingStyle-help" title="{{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical')}}">
|
||||
<i class="fa {{writingStyleModel === WritingStyle.Horizontal ? 'fa-arrows-left-right' : 'fa-arrows-up-down' }}" aria-hidden="true"></i>
|
||||
<span class="phone-hidden"> {{writingStyleModel === WritingStyle.Horizontal ? t('horizontal') : t('vertical') }}</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="tap-pagination" class="form-label">{{t('tap-to-paginate-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
|
||||
<ng-template #tapPaginationTooltip>{{t('tap-to-paginate-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="tapPagination-help">
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="tap-pagination" class="form-label">{{t('tap-to-paginate-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="tapPaginationTooltip" role="button" tabindex="0" aria-describedby="tapPagination-help"></i></label>
|
||||
<ng-template #tapPaginationTooltip>{{t('tap-to-paginate-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="tapPagination-help">
|
||||
<ng-container [ngTemplateOutlet]="tapPaginationTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
|
||||
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? t('on') : t('off')}} </label>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
|
||||
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? t('on') : t('off')}} </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="immersive-mode" class="form-label">{{t('immersive-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
|
||||
<ng-template #immersiveModeTooltip>{{t('immersive-mode-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="immersiveMode-help">
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label for="immersive-mode" class="form-label">{{t('immersive-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="immersiveModeTooltip" role="button" tabindex="0" aria-describedby="immersiveMode-help"></i></label>
|
||||
<ng-template #immersiveModeTooltip>{{t('immersive-mode-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="immersiveMode-help">
|
||||
<ng-container [ngTemplateOutlet]="immersiveModeTooltip"></ng-container>
|
||||
</span>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
|
||||
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? t('on') : t('off')}} </label>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
|
||||
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? t('on') : t('off')}} </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="fullscreen" class="form-label">{{t('fullscreen-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top"
|
||||
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
|
||||
<ng-template #fullscreenTooltip>{{t('fullscreen-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="fullscreen-help">
|
||||
<div class="controls" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<label id="fullscreen" class="form-label">{{t('fullscreen-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top"
|
||||
[ngbTooltip]="fullscreenTooltip" role="button" tabindex="1" aria-describedby="fullscreen-help"></i></label>
|
||||
<ng-template #fullscreenTooltip>{{t('fullscreen-tooltip')}}</ng-template>
|
||||
<span class="visually-hidden" id="fullscreen-help">
|
||||
<ng-container [ngTemplateOutlet]="fullscreenTooltip"></ng-container>
|
||||
</span>
|
||||
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
||||
<i class="fa {{isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
||||
<span *ngIf="activeTheme?.isDarkTheme"> {{isFullscreen ? t('exit') : t('enter')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button (click)="toggleFullscreen()" class="btn btn-icon" aria-labelledby="fullscreen">
|
||||
<i class="fa {{isFullscreen ? 'fa-compress-alt' : 'fa-expand-alt'}} {{isFullscreen ? 'icon-primary-color' : ''}}" aria-hidden="true"></i>
|
||||
<span *ngIf="activeTheme?.isDarkTheme"> {{isFullscreen ? t('exit') : t('enter')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">{{t('layout-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
|
||||
<ng-template #layoutTooltip><span [innerHTML]="t('layout-mode-tooltip')"></span></ng-template>
|
||||
<span class="visually-hidden" id="layout-help">
|
||||
<div class="controls">
|
||||
<label id="layout-mode" class="form-label" style="margin-bottom:0.5rem">{{t('layout-mode-label')}}<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="layoutTooltip" role="button" tabindex="1" aria-describedby="layout-help"></i></label>
|
||||
<ng-template #layoutTooltip><span [innerHTML]="t('layout-mode-tooltip')"></span></ng-template>
|
||||
<span class="visually-hidden" id="layout-help">
|
||||
<ng-container [ngTemplateOutlet]="layoutTooltip"></ng-container>
|
||||
</span>
|
||||
<br>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('layout-mode-label')">
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-scroll')}}</label>
|
||||
<br>
|
||||
<div class="btn-group d-flex justify-content-center" role="group" [attr.aria-label]="t('layout-mode-label')">
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Default" class="btn-check" id="layout-mode-default" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-default">{{t('layout-mode-option-scroll')}}</label>
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-1col')}}</label>
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column1" class="btn-check" id="layout-mode-col1" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col1">{{t('layout-mode-option-1col')}}</label>
|
||||
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col2">{{t('layout-mode-option-2col')}}</label>
|
||||
<input type="radio" formControlName="layoutMode" [value]="BookPageLayoutMode.Column2" class="btn-check" id="layout-mode-col2" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="layout-mode-col2">{{t('layout-mode-option-2col')}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ngbAccordionItem id="color-panel" [title]="t('color-theme-title')" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
|
||||
{{t('color-theme-title')}}
|
||||
<div ngbAccordionItem id="color-panel" [title]="t('color-theme-title')" [collapsed]="false">
|
||||
<h2 class="accordion-header" ngbAccordionHeader>
|
||||
<button class="accordion-button" ngbAccordionButton type="button" [attr.aria-expanded]="acc.isExpanded('color-panel')" aria-controls="collapseOne">
|
||||
{{t('color-theme-title')}}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls">
|
||||
<ng-container *ngFor="let theme of themes">
|
||||
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
|
||||
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
|
||||
{{t(theme.translationKey)}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-2">
|
||||
<button class="btn btn-primary col-12 mb-2"
|
||||
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit || !parentReadingProfile"
|
||||
(click)="updateParentPref()">
|
||||
{{ t('update-parent', {name: parentReadingProfile?.name || t('loading')}) }}
|
||||
</button>
|
||||
<button class="btn btn-primary col-12 mb-2"
|
||||
[ngbTooltip]="t('create-new-tooltip')"
|
||||
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit"
|
||||
(click)="createNewProfileFromImplicit()">
|
||||
{{ t('create-new') }}
|
||||
</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
<div class="controls">
|
||||
<ng-container *ngFor="let theme of themes">
|
||||
<button class="btn btn-icon color" (click)="setTheme(theme.name)" [ngClass]="{'active': activeTheme?.name === theme.name}">
|
||||
<div class="dot" [ngStyle]="{'background-color': theme.colorHash}"></div>
|
||||
{{t(theme.translationKey)}}
|
||||
</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</ng-container>
|
||||
}
|
||||
|
@ -1,32 +1,46 @@
|
||||
import { DOCUMENT, NgFor, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe } from '@angular/common';
|
||||
import {DOCUMENT, NgClass, NgFor, NgIf, NgStyle, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { take } from 'rxjs';
|
||||
import { BookPageLayoutMode } from 'src/app/_models/readers/book-page-layout-mode';
|
||||
import { BookTheme } from 'src/app/_models/preferences/book-theme';
|
||||
import { ReadingDirection } from 'src/app/_models/preferences/reading-direction';
|
||||
import { WritingStyle } from 'src/app/_models/preferences/writing-style';
|
||||
import { ThemeProvider } from 'src/app/_models/preferences/site-theme';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { ThemeService } from 'src/app/_services/theme.service';
|
||||
import { FontFamily, BookService } from '../../_services/book.service';
|
||||
import { BookBlackTheme } from '../../_models/book-black-theme';
|
||||
import { BookDarkTheme } from '../../_models/book-dark-theme';
|
||||
import { BookWhiteTheme } from '../../_models/book-white-theme';
|
||||
import { BookPaperTheme } from '../../_models/book-paper-theme';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
|
||||
import {skip, take} from 'rxjs';
|
||||
import {BookPageLayoutMode} from 'src/app/_models/readers/book-page-layout-mode';
|
||||
import {BookTheme} from 'src/app/_models/preferences/book-theme';
|
||||
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
|
||||
import {WritingStyle} from 'src/app/_models/preferences/writing-style';
|
||||
import {ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
||||
import {User} from 'src/app/_models/user';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ThemeService} from 'src/app/_services/theme.service';
|
||||
import {BookService, FontFamily} from '../../_services/book.service';
|
||||
import {BookBlackTheme} from '../../_models/book-black-theme';
|
||||
import {BookDarkTheme} from '../../_models/book-dark-theme';
|
||||
import {BookWhiteTheme} from '../../_models/book-white-theme';
|
||||
import {BookPaperTheme} from '../../_models/book-paper-theme';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {
|
||||
NgbAccordionBody,
|
||||
NgbAccordionButton,
|
||||
NgbAccordionCollapse,
|
||||
NgbAccordionDirective,
|
||||
NgbAccordionHeader,
|
||||
NgbAccordionItem,
|
||||
NgbTooltip
|
||||
} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
|
||||
import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
/**
|
||||
* Used for book reader. Do not use for other components
|
||||
@ -89,9 +103,13 @@ const mobileBreakpointMarginOverride = 700;
|
||||
templateUrl: './reader-settings.component.html',
|
||||
styleUrls: ['./reader-settings.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle, TitleCasePipe, TranslocoDirective]
|
||||
imports: [ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader, NgbAccordionButton,
|
||||
NgbAccordionCollapse, NgbAccordionBody, NgFor, NgbTooltip, NgTemplateOutlet, NgIf, NgClass, NgStyle,
|
||||
TitleCasePipe, TranslocoDirective]
|
||||
})
|
||||
export class ReaderSettingsComponent implements OnInit {
|
||||
@Input({required:true}) seriesId!: number;
|
||||
@Input({required:true}) readingProfile!: ReadingProfile;
|
||||
/**
|
||||
* Outputs when clickToPaginate is changed
|
||||
*/
|
||||
@ -147,6 +165,11 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
/**
|
||||
* The reading profile itself, unless readingProfile is implicit
|
||||
*/
|
||||
parentReadingProfile: ReadingProfile | null = null;
|
||||
|
||||
/**
|
||||
* System provided themes
|
||||
*/
|
||||
@ -166,136 +189,169 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
return WritingStyle;
|
||||
}
|
||||
|
||||
|
||||
|
||||
constructor(private bookService: BookService, private accountService: AccountService,
|
||||
@Inject(DOCUMENT) private document: Document, private themeService: ThemeService,
|
||||
private readonly cdRef: ChangeDetectorRef) {}
|
||||
private readonly cdRef: ChangeDetectorRef, private readingProfileService: ReadingProfileService,
|
||||
private toastr: ToastrService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
|
||||
this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => {
|
||||
this.parentReadingProfile = parent;
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
} else {
|
||||
this.parentReadingProfile = this.readingProfile;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
this.fontFamilies = this.bookService.getFontFamilies();
|
||||
this.fontOptions = this.fontFamilies.map(f => f.title);
|
||||
|
||||
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.setupSettings();
|
||||
|
||||
this.setTheme(this.readingProfile.bookReaderThemeName || this.themeService.defaultBookTheme, false);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// Emit first time so book reader gets the setting
|
||||
this.readingDirection.emit(this.readingDirectionModel);
|
||||
this.bookReaderWritingStyle.emit(this.writingStyleModel);
|
||||
this.clickToPaginateChanged.emit(this.readingProfile.bookReaderTapToPaginate);
|
||||
this.layoutModeUpdate.emit(this.readingProfile.bookReaderLayoutMode);
|
||||
this.immersiveMode.emit(this.readingProfile.bookReaderImmersiveMode);
|
||||
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
|
||||
if (this.user.preferences.bookReaderFontFamily === undefined) {
|
||||
this.user.preferences.bookReaderFontFamily = 'default';
|
||||
}
|
||||
if (this.user.preferences.bookReaderFontSize === undefined || this.user.preferences.bookReaderFontSize < 50) {
|
||||
this.user.preferences.bookReaderFontSize = 100;
|
||||
}
|
||||
if (this.user.preferences.bookReaderLineSpacing === undefined || this.user.preferences.bookReaderLineSpacing < 100) {
|
||||
this.user.preferences.bookReaderLineSpacing = 100;
|
||||
}
|
||||
if (this.user.preferences.bookReaderMargin === undefined) {
|
||||
this.user.preferences.bookReaderMargin = 0;
|
||||
}
|
||||
if (this.user.preferences.bookReaderReadingDirection === undefined) {
|
||||
this.user.preferences.bookReaderReadingDirection = ReadingDirection.LeftToRight;
|
||||
}
|
||||
if (this.user.preferences.bookReaderWritingStyle === undefined) {
|
||||
this.user.preferences.bookReaderWritingStyle = WritingStyle.Horizontal;
|
||||
}
|
||||
this.readingDirectionModel = this.user.preferences.bookReaderReadingDirection;
|
||||
this.writingStyleModel = this.user.preferences.bookReaderWritingStyle;
|
||||
|
||||
|
||||
|
||||
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
|
||||
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => {
|
||||
const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family;
|
||||
if (familyName === 'default') {
|
||||
this.pageStyles['font-family'] = 'inherit';
|
||||
} else {
|
||||
this.pageStyles['font-family'] = "'" + familyName + "'";
|
||||
}
|
||||
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, []));
|
||||
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.pageStyles['font-size'] = value + '%';
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, []));
|
||||
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.clickToPaginateChanged.emit(value);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, []));
|
||||
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.pageStyles['line-height'] = value + '%';
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, []));
|
||||
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.pageStyles['margin-left'] = value + 'vw';
|
||||
this.pageStyles['margin-right'] = value + 'vw';
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
|
||||
|
||||
this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, []));
|
||||
this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => {
|
||||
this.layoutModeUpdate.emit(layoutMode);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user.preferences.bookReaderImmersiveMode, []));
|
||||
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => {
|
||||
if (immersiveMode) {
|
||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
|
||||
}
|
||||
this.immersiveMode.emit(immersiveMode);
|
||||
});
|
||||
|
||||
|
||||
this.setTheme(this.user.preferences.bookReaderThemeName || this.themeService.defaultBookTheme);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// Emit first time so book reader gets the setting
|
||||
this.readingDirection.emit(this.readingDirectionModel);
|
||||
this.bookReaderWritingStyle.emit(this.writingStyleModel);
|
||||
this.clickToPaginateChanged.emit(this.user.preferences.bookReaderTapToPaginate);
|
||||
this.layoutModeUpdate.emit(this.user.preferences.bookReaderLayoutMode);
|
||||
this.immersiveMode.emit(this.user.preferences.bookReaderImmersiveMode);
|
||||
|
||||
this.resetSettings();
|
||||
} else {
|
||||
this.resetSettings();
|
||||
}
|
||||
|
||||
|
||||
// User needs to be loaded before we call this
|
||||
this.resetSettings();
|
||||
});
|
||||
}
|
||||
|
||||
setupSettings() {
|
||||
if (!this.readingProfile) return;
|
||||
|
||||
if (this.readingProfile.bookReaderFontFamily === undefined) {
|
||||
this.readingProfile.bookReaderFontFamily = 'default';
|
||||
}
|
||||
if (this.readingProfile.bookReaderFontSize === undefined || this.readingProfile.bookReaderFontSize < 50) {
|
||||
this.readingProfile.bookReaderFontSize = 100;
|
||||
}
|
||||
if (this.readingProfile.bookReaderLineSpacing === undefined || this.readingProfile.bookReaderLineSpacing < 100) {
|
||||
this.readingProfile.bookReaderLineSpacing = 100;
|
||||
}
|
||||
if (this.readingProfile.bookReaderMargin === undefined) {
|
||||
this.readingProfile.bookReaderMargin = 0;
|
||||
}
|
||||
if (this.readingProfile.bookReaderReadingDirection === undefined) {
|
||||
this.readingProfile.bookReaderReadingDirection = ReadingDirection.LeftToRight;
|
||||
}
|
||||
if (this.readingProfile.bookReaderWritingStyle === undefined) {
|
||||
this.readingProfile.bookReaderWritingStyle = WritingStyle.Horizontal;
|
||||
}
|
||||
this.readingDirectionModel = this.readingProfile.bookReaderReadingDirection;
|
||||
this.writingStyleModel = this.readingProfile.bookReaderWritingStyle;
|
||||
|
||||
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.readingProfile.bookReaderFontFamily, []));
|
||||
this.settingsForm.get('bookReaderFontFamily')!.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(fontName => {
|
||||
const familyName = this.fontFamilies.filter(f => f.title === fontName)[0].family;
|
||||
if (familyName === 'default') {
|
||||
this.pageStyles['font-family'] = 'inherit';
|
||||
} else {
|
||||
this.pageStyles['font-family'] = "'" + familyName + "'";
|
||||
}
|
||||
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.readingProfile.bookReaderFontSize, []));
|
||||
this.settingsForm.get('bookReaderFontSize')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.pageStyles['font-size'] = value + '%';
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.readingProfile.bookReaderTapToPaginate, []));
|
||||
this.settingsForm.get('bookReaderTapToPaginate')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.clickToPaginateChanged.emit(value);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.readingProfile.bookReaderLineSpacing, []));
|
||||
this.settingsForm.get('bookReaderLineSpacing')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.pageStyles['line-height'] = value + '%';
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.readingProfile.bookReaderMargin, []));
|
||||
this.settingsForm.get('bookReaderMargin')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(value => {
|
||||
this.pageStyles['margin-left'] = value + 'vw';
|
||||
this.pageStyles['margin-right'] = value + 'vw';
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('layoutMode', new FormControl(this.readingProfile.bookReaderLayoutMode, []));
|
||||
this.settingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((layoutMode: BookPageLayoutMode) => {
|
||||
this.layoutModeUpdate.emit(layoutMode);
|
||||
});
|
||||
|
||||
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.readingProfile.bookReaderImmersiveMode, []));
|
||||
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((immersiveMode: boolean) => {
|
||||
if (immersiveMode) {
|
||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
|
||||
}
|
||||
this.immersiveMode.emit(immersiveMode);
|
||||
});
|
||||
|
||||
// Update implicit reading profile while changing settings
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
skip(1), // Skip the initial creation of the form, we do not want an implicit profile of this snapshot
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap(_ => this.updateImplicit())
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
resetSettings() {
|
||||
if (!this.readingProfile) return;
|
||||
|
||||
if (this.user) {
|
||||
this.setPageStyles(this.user.preferences.bookReaderFontFamily, this.user.preferences.bookReaderFontSize + '%', this.user.preferences.bookReaderMargin + 'vw', this.user.preferences.bookReaderLineSpacing + '%');
|
||||
this.setPageStyles(this.readingProfile.bookReaderFontFamily, this.readingProfile.bookReaderFontSize + '%', this.readingProfile.bookReaderMargin + 'vw', this.readingProfile.bookReaderLineSpacing + '%');
|
||||
} else {
|
||||
this.setPageStyles();
|
||||
}
|
||||
|
||||
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.user.preferences.bookReaderFontFamily);
|
||||
this.settingsForm.get('bookReaderFontSize')?.setValue(this.user.preferences.bookReaderFontSize);
|
||||
this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.user.preferences.bookReaderLineSpacing);
|
||||
this.settingsForm.get('bookReaderMargin')?.setValue(this.user.preferences.bookReaderMargin);
|
||||
this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.user.preferences.bookReaderReadingDirection);
|
||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.user.preferences.bookReaderTapToPaginate);
|
||||
this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.user.preferences.bookReaderLayoutMode);
|
||||
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.user.preferences.bookReaderImmersiveMode);
|
||||
this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.user.preferences.bookReaderWritingStyle);
|
||||
this.settingsForm.get('bookReaderFontFamily')?.setValue(this.readingProfile.bookReaderFontFamily);
|
||||
this.settingsForm.get('bookReaderFontSize')?.setValue(this.readingProfile.bookReaderFontSize);
|
||||
this.settingsForm.get('bookReaderLineSpacing')?.setValue(this.readingProfile.bookReaderLineSpacing);
|
||||
this.settingsForm.get('bookReaderMargin')?.setValue(this.readingProfile.bookReaderMargin);
|
||||
this.settingsForm.get('bookReaderReadingDirection')?.setValue(this.readingProfile.bookReaderReadingDirection);
|
||||
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(this.readingProfile.bookReaderTapToPaginate);
|
||||
this.settingsForm.get('bookReaderLayoutMode')?.setValue(this.readingProfile.bookReaderLayoutMode);
|
||||
this.settingsForm.get('bookReaderImmersiveMode')?.setValue(this.readingProfile.bookReaderImmersiveMode);
|
||||
this.settingsForm.get('bookReaderWritingStyle')?.setValue(this.readingProfile.bookReaderWritingStyle);
|
||||
|
||||
this.cdRef.detectChanges();
|
||||
this.styleUpdate.emit(this.pageStyles);
|
||||
}
|
||||
|
||||
updateImplicit() {
|
||||
this.readingProfileService.updateImplicit(this.packReadingProfile(), this.seriesId).subscribe({
|
||||
next: newProfile => {
|
||||
this.readingProfile = newProfile;
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to be used by resetSettings. Pass items in with quantifiers
|
||||
*/
|
||||
@ -318,11 +374,15 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
};
|
||||
}
|
||||
|
||||
setTheme(themeName: string) {
|
||||
setTheme(themeName: string, update: boolean = true) {
|
||||
const theme = this.themes.find(t => t.name === themeName);
|
||||
this.activeTheme = theme;
|
||||
this.cdRef.markForCheck();
|
||||
this.colorThemeUpdate.emit(theme);
|
||||
|
||||
if (update) {
|
||||
this.updateImplicit();
|
||||
}
|
||||
}
|
||||
|
||||
toggleReadingDirection() {
|
||||
@ -334,6 +394,7 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
this.readingDirection.emit(this.readingDirectionModel);
|
||||
this.updateImplicit();
|
||||
}
|
||||
|
||||
toggleWritingStyle() {
|
||||
@ -345,6 +406,7 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
this.bookReaderWritingStyle.emit(this.writingStyleModel);
|
||||
this.updateImplicit();
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
@ -352,4 +414,53 @@ export class ReaderSettingsComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
this.fullscreen.emit();
|
||||
}
|
||||
|
||||
// menu only code
|
||||
updateParentPref() {
|
||||
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => {
|
||||
this.readingProfile = newProfile;
|
||||
this.toastr.success(translate('manga-reader.reading-profile-updated'));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
createNewProfileFromImplicit() {
|
||||
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
|
||||
this.readingProfile = newProfile;
|
||||
this.parentReadingProfile = newProfile; // profile is no longer implicit
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.toastr.success(translate("manga-reader.reading-profile-promoted"));
|
||||
});
|
||||
}
|
||||
|
||||
private packReadingProfile(): ReadingProfile {
|
||||
const modelSettings = this.settingsForm.getRawValue();
|
||||
const data = {...this.readingProfile!};
|
||||
data.bookReaderFontFamily = modelSettings.bookReaderFontFamily;
|
||||
data.bookReaderFontSize = modelSettings.bookReaderFontSize
|
||||
data.bookReaderLineSpacing = modelSettings.bookReaderLineSpacing;
|
||||
data.bookReaderMargin = modelSettings.bookReaderMargin;
|
||||
data.bookReaderTapToPaginate = modelSettings.bookReaderTapToPaginate;
|
||||
data.bookReaderLayoutMode = modelSettings.layoutMode;
|
||||
data.bookReaderImmersiveMode = modelSettings.bookReaderImmersiveMode;
|
||||
|
||||
data.bookReaderReadingDirection = this.readingDirectionModel;
|
||||
data.bookReaderWritingStyle = this.writingStyleModel;
|
||||
if (this.activeTheme) {
|
||||
data.bookReaderThemeName = this.activeTheme.name;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
protected readonly ReadingProfileKind = ReadingProfileKind;
|
||||
}
|
||||
|
@ -48,6 +48,7 @@ export const BookPaperTheme = `
|
||||
--btn-disabled-bg-color: #343a40;
|
||||
--btn-disabled-text-color: #efefef;
|
||||
--btn-disabled-border-color: #6c757d;
|
||||
--btn-outline-primary-text-color: black;
|
||||
|
||||
/* Inputs */
|
||||
--input-bg-color: white;
|
||||
@ -89,6 +90,8 @@ export const BookPaperTheme = `
|
||||
|
||||
/* Custom variables */
|
||||
--theme-bg-color: #fff3c9;
|
||||
|
||||
--bs-secondary-bg: darkgrey;
|
||||
}
|
||||
|
||||
.reader-container {
|
||||
|
@ -51,6 +51,7 @@ export const BookWhiteTheme = `
|
||||
--btn-disabled-bg-color: #343a40;
|
||||
--btn-disabled-text-color: #efefef;
|
||||
--btn-disabled-border-color: #6c757d;
|
||||
--btn-outline-primary-text-color: black;
|
||||
|
||||
/* Inputs */
|
||||
--input-bg-color: white;
|
||||
|
@ -0,0 +1,56 @@
|
||||
<ng-container *transloco="let t; prefix: 'bulk-set-reading-profile-modal'">
|
||||
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</div>
|
||||
<form [formGroup]="profileForm">
|
||||
<div class="modal-body">
|
||||
@if (profiles.length >= MaxItems) {
|
||||
<div class="mb-3">
|
||||
<label for="filter" class="form-label">{{t('filter-label')}}</label>
|
||||
<div class="input-group">
|
||||
<input id="filter" autocomplete="off" class="form-control" formControlName="filterQuery" type="text" aria-describedby="reset-input">
|
||||
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="clear()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="list-group">
|
||||
@for(profile of profiles | filter: filterList; let i = $index; track profile.name) {
|
||||
<li class="list-group-item clickable" tabindex="0" role="option" (click)="addToProfile(profile)">
|
||||
<div class="p-2 group-item d-flex justify-content-between align-items-center">
|
||||
|
||||
<div class="fw-bold">{{profile.name | sentenceCase}}</div>
|
||||
|
||||
@if (currentProfile && currentProfile.name === profile.name) {
|
||||
<span class="pill p-1 ms-1">{{t('bound')}}</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (profiles.length === 0 && !isLoading) {
|
||||
<li class="list-group-item">{{t('no-data')}}</li>
|
||||
}
|
||||
|
||||
@if (isLoading) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{t('loading')}}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,14 @@
|
||||
.clickable:hover, .clickable:focus {
|
||||
background-color: var(--list-group-hover-bg-color, --primary-color);
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: .8rem;
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--badge-text-color);
|
||||
|
||||
&.active {
|
||||
background-color : var(--primary-color);
|
||||
}
|
||||
}
|
@ -0,0 +1,120 @@
|
||||
import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, inject, Input, OnInit, ViewChild} from '@angular/core';
|
||||
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {ReadingProfile, ReadingProfileKind} from "../../../_models/preferences/reading-profiles";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-set-reading-profile-modal',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
FilterPipe,
|
||||
TranslocoDirective,
|
||||
SentenceCasePipe
|
||||
],
|
||||
templateUrl: './bulk-set-reading-profile-modal.component.html',
|
||||
styleUrl: './bulk-set-reading-profile-modal.component.scss'
|
||||
})
|
||||
export class BulkSetReadingProfileModalComponent implements OnInit, AfterViewInit {
|
||||
private readonly modal = inject(NgbActiveModal);
|
||||
private readonly readingProfileService = inject(ReadingProfileService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
protected readonly MaxItems = 8;
|
||||
|
||||
/**
|
||||
* Modal Header - since this code is used for multiple flows
|
||||
*/
|
||||
@Input({required: true}) title!: string;
|
||||
/**
|
||||
* Series Ids to add to Reading Profile
|
||||
*/
|
||||
@Input() seriesIds: Array<number> = [];
|
||||
@Input() libraryId: number | undefined;
|
||||
@ViewChild('title') inputElem!: ElementRef<HTMLInputElement>;
|
||||
|
||||
currentProfile: ReadingProfile | null = null;
|
||||
profiles: Array<ReadingProfile> = [];
|
||||
isLoading: boolean = false;
|
||||
profileForm: FormGroup = new FormGroup({
|
||||
filterQuery: new FormControl('', []), // Used for inline filtering when too many RPs
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.profileForm.addControl('title', new FormControl(this.title, []));
|
||||
|
||||
this.isLoading = true;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (this.libraryId !== undefined) {
|
||||
this.readingProfileService.getForLibrary(this.libraryId).subscribe(profile => {
|
||||
this.currentProfile = profile;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
} else if (this.seriesIds.length === 1) {
|
||||
this.readingProfileService.getForSeries(this.seriesIds[0], true).subscribe(profile => {
|
||||
this.currentProfile = profile;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
this.readingProfileService.getAllProfiles().subscribe(profiles => {
|
||||
this.profiles = profiles;
|
||||
this.isLoading = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
// Shift focus to input
|
||||
if (this.inputElem) {
|
||||
this.inputElem.nativeElement.select();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close();
|
||||
}
|
||||
|
||||
addToProfile(profile: ReadingProfile) {
|
||||
if (this.seriesIds.length == 1) {
|
||||
this.readingProfileService.addToSeries(profile.id, this.seriesIds[0]).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.series-bound-to-reading-profile', {name: profile.name}));
|
||||
this.modal.close();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.seriesIds.length > 1) {
|
||||
this.readingProfileService.bulkAddToSeries(profile.id, this.seriesIds).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.series-bound-to-reading-profile', {name: profile.name}));
|
||||
this.modal.close();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.libraryId) {
|
||||
this.readingProfileService.addToLibrary(profile.id, this.libraryId).subscribe(() => {
|
||||
this.toastr.success(translate('toasts.library-bound-to-reading-profile', {name: profile.name}));
|
||||
this.modal.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
filterList = (listItem: ReadingProfile) => {
|
||||
return listItem.name.toLowerCase().indexOf((this.profileForm.value.filterQuery || '').toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.profileForm.get('filterQuery')?.setValue('');
|
||||
}
|
||||
|
||||
protected readonly ReadingProfileKind = ReadingProfileKind;
|
||||
}
|
@ -144,7 +144,7 @@ export class BulkSelectionService {
|
||||
*/
|
||||
getActions(callback: (action: ActionItem<any>, data: any) => void) {
|
||||
const allowedActions = [Action.AddToReadingList, Action.MarkAsRead, Action.MarkAsUnread, Action.AddToCollection,
|
||||
Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList];
|
||||
Action.Delete, Action.AddToWantToReadList, Action.RemoveFromWantToReadList, Action.SetReadingProfile];
|
||||
|
||||
if (Object.keys(this.selectedCards).filter(item => item === 'series').length > 0) {
|
||||
return this.applyFilterToList(this.actionFactory.getSeriesActions(callback), allowedActions);
|
||||
|
@ -24,7 +24,7 @@ import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-drawer/series-preview-drawer.component";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
@ -41,6 +41,7 @@ import {ScrollService} from "../../_services/scroll.service";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {ReadingProfileService} from "../../_services/reading-profile.service";
|
||||
|
||||
function deepClone(obj: any): any {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
@ -92,6 +93,8 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
private readonly readingProfilesService = inject(ReadingProfileService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
@Input({required: true}) series!: Series;
|
||||
@Input() libraryId = 0;
|
||||
@ -276,6 +279,14 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
case Action.Download:
|
||||
this.downloadService.download('series', this.series);
|
||||
break;
|
||||
case Action.SetReadingProfile:
|
||||
this.actionService.setReadingProfileForMultiple([series]);
|
||||
break;
|
||||
case Action.ClearReadingProfile:
|
||||
this.readingProfilesService.clearSeriesProfiles(series.id).subscribe(() => {
|
||||
this.toastr.success(this.translocoService.translate('actionable.cleared-profile'));
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -149,6 +149,14 @@ export class LibraryDetailComponent implements OnInit {
|
||||
this.loadPage();
|
||||
});
|
||||
break;
|
||||
case Action.SetReadingProfile:
|
||||
this.actionService.setReadingProfileForMultiple(selectedSeries, (success) => {
|
||||
this.bulkLoader = false;
|
||||
this.cdRef.markForCheck();
|
||||
if (!success) return;
|
||||
this.bulkSelectionService.deselectAll();
|
||||
this.loadPage();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,7 +53,6 @@
|
||||
<div class="reading-area"
|
||||
ngSwipe (swipeEnd)="onSwipeEnd($event)" (swipeMove)="onSwipeMove($event)"
|
||||
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea>
|
||||
|
||||
@if (readerMode !== ReaderMode.Webtoon) {
|
||||
<div appDblClick (dblclick)="bookmarkPage($event)" (singleClick)="toggleMenu()">
|
||||
<app-canvas-renderer
|
||||
@ -311,7 +310,17 @@
|
||||
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-primary" (click)="savePref()">{{t('save-globally')}}</button>
|
||||
<button class="btn btn-primary"
|
||||
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit || !parentReadingProfile"
|
||||
(click)="updateParentPref()">
|
||||
{{ t('update-parent', {name: parentReadingProfile?.name || t('loading')}) }}
|
||||
</button>
|
||||
<button class="btn btn-primary ms-sm-2"
|
||||
[ngbTooltip]="t('create-new-tooltip')"
|
||||
[disabled]="readingProfile.kind !== ReadingProfileKind.Implicit"
|
||||
(click)="createNewProfileFromImplicit()">
|
||||
{{ t('create-new') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -32,7 +32,7 @@ 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} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgbModal, 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';
|
||||
@ -40,7 +40,6 @@ import {Breakpoint, KEY_CODES, UtilityService} from 'src/app/shared/_services/ut
|
||||
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';
|
||||
import {layoutModes, pageSplitOptions} from 'src/app/_models/preferences/preferences';
|
||||
import {ReaderMode} from 'src/app/_models/preferences/reader-mode';
|
||||
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
|
||||
import {ScalingOption} from 'src/app/_models/preferences/scaling-option';
|
||||
@ -70,6 +69,13 @@ import {LoadingComponent} from '../../../shared/loading/loading.component';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
import {DblClickDirective} from "../../../_directives/dbl-click.directive";
|
||||
import {
|
||||
layoutModes,
|
||||
pageSplitOptions,
|
||||
ReadingProfile,
|
||||
ReadingProfileKind
|
||||
} from "../../../_models/preferences/reading-profiles";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
import {ConfirmService} from "../../../shared/confirm.service";
|
||||
|
||||
|
||||
@ -123,10 +129,10 @@ enum KeyDirection {
|
||||
])
|
||||
])
|
||||
],
|
||||
imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
|
||||
DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent,
|
||||
NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe,
|
||||
FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective]
|
||||
imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
|
||||
DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent,
|
||||
NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe,
|
||||
FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective, NgbTooltip]
|
||||
})
|
||||
export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
@ -151,6 +157,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly readingProfileService = inject(ReadingProfileService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
protected readonly readerService = inject(ReaderService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
@ -197,6 +204,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
totalSeriesPages = 0;
|
||||
totalSeriesPagesRead = 0;
|
||||
user!: User;
|
||||
readingProfile!: ReadingProfile;
|
||||
/**
|
||||
* The reading profile itself, unless readingProfile is implicit
|
||||
*/
|
||||
parentReadingProfile: ReadingProfile | null = null;
|
||||
generalSettingsForm!: FormGroup;
|
||||
|
||||
readingDirection = ReadingDirection.LeftToRight;
|
||||
@ -491,6 +503,17 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
|
||||
this.bookmarkMode = this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true';
|
||||
|
||||
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.readingProfile = data['readingProfile'];
|
||||
if (this.readingProfile == null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
return;
|
||||
}
|
||||
// Requires seriesId to be set
|
||||
this.setupReaderSettings();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
|
||||
if (readingListId != null) {
|
||||
this.readingListMode = true;
|
||||
@ -505,101 +528,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.user = user;
|
||||
this.hasBookmarkRights = this.accountService.hasBookmarkRole(user) || this.accountService.hasAdminRole(user);
|
||||
this.readingDirection = this.user.preferences.readingDirection;
|
||||
this.scalingOption = this.user.preferences.scalingOption;
|
||||
this.pageSplitOption = this.user.preferences.pageSplitOption;
|
||||
this.autoCloseMenu = this.user.preferences.autoCloseMenu;
|
||||
this.readerMode = this.user.preferences.readerMode;
|
||||
this.layoutMode = this.user.preferences.layoutMode || LayoutMode.Single;
|
||||
this.backgroundColor = this.user.preferences.backgroundColor || '#000000';
|
||||
this.readerService.setOverrideStyles(this.backgroundColor);
|
||||
|
||||
this.generalSettingsForm = this.formBuilder.nonNullable.group({
|
||||
autoCloseMenu: new FormControl(this.autoCloseMenu),
|
||||
pageSplitOption: new FormControl(this.pageSplitOption),
|
||||
fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)),
|
||||
widthSlider: new FormControl('none'),
|
||||
layoutMode: new FormControl(this.layoutMode),
|
||||
darkness: new FormControl(100),
|
||||
emulateBook: new FormControl(this.user.preferences.emulateBook),
|
||||
swipeToPaginate: new FormControl(this.user.preferences.swipeToPaginate)
|
||||
});
|
||||
|
||||
this.readerModeSubject.next(this.readerMode);
|
||||
this.pagingDirectionSubject.next(this.pagingDirection);
|
||||
|
||||
// We need a mergeMap when page changes
|
||||
this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe(
|
||||
map(_ => this.createReaderSettingsUpdate()),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
);
|
||||
|
||||
this.updateForm();
|
||||
|
||||
this.pagingDirection$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(dir => {
|
||||
this.pagingDirection = dir;
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {});
|
||||
|
||||
this.readerMode$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(mode => {
|
||||
this.readerMode = mode;
|
||||
this.disableDoubleRendererIfScreenTooSmall();
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {});
|
||||
|
||||
this.setupWidthOverrideTriggers();
|
||||
|
||||
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
|
||||
const changeOccurred = parseInt(val, 10) !== this.layoutMode;
|
||||
this.layoutMode = parseInt(val, 10);
|
||||
|
||||
if (this.layoutMode === LayoutMode.Single) {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption);
|
||||
this.generalSettingsForm.get('pageSplitOption')?.enable();
|
||||
this.generalSettingsForm.get('widthSlider')?.enable();
|
||||
this.generalSettingsForm.get('fittingOption')?.enable();
|
||||
this.generalSettingsForm.get('emulateBook')?.enable();
|
||||
} else {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit);
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable();
|
||||
this.generalSettingsForm.get('widthSlider')?.disable();
|
||||
this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight));
|
||||
this.generalSettingsForm.get('fittingOption')?.disable();
|
||||
this.generalSettingsForm.get('emulateBook')?.enable();
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// Re-render the current page when we switch layouts
|
||||
if (changeOccurred) {
|
||||
this.setPageNum(this.adjustPagesForDoubleRenderer(this.pageNum));
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
this.generalSettingsForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
||||
this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10);
|
||||
|
||||
const needsSplitting = this.mangaReaderService.isWidePage(this.readerService.imageUrlToPageNum(this.canvasImage.src));
|
||||
// If we need to split on a menu change, then we need to re-render.
|
||||
if (needsSplitting) {
|
||||
// If we need to re-render, to ensure things layout properly, let's update paging direction & reset render
|
||||
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
|
||||
this.canvasRenderer.reset();
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
this.memberService.hasReadingProgress(this.libraryId).pipe(take(1)).subscribe(progress => {
|
||||
if (!progress) {
|
||||
@ -607,9 +538,29 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.toastr.info(translate('manga-reader.first-time-reading-manga'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.init();
|
||||
this.init();
|
||||
|
||||
// Update implicit reading profile while changing settings
|
||||
this.generalSettingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(_ => this.packReadingProfile()),
|
||||
distinctUntilChanged(),
|
||||
tap(newProfile => {
|
||||
this.readingProfileService.updateImplicit(newProfile, this.seriesId).subscribe({
|
||||
next: updatedProfile => {
|
||||
this.readingProfile = updatedProfile;
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
}
|
||||
})
|
||||
})
|
||||
).subscribe();
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
@ -697,6 +648,114 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
setupReaderSettings() {
|
||||
|
||||
if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
|
||||
this.readingProfileService.getForSeries(this.seriesId, true).subscribe(parent => {
|
||||
this.parentReadingProfile = parent;
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
} else {
|
||||
this.parentReadingProfile = this.readingProfile;
|
||||
}
|
||||
|
||||
this.readingDirection = this.readingProfile.readingDirection;
|
||||
this.scalingOption = this.readingProfile.scalingOption;
|
||||
this.pageSplitOption = this.readingProfile.pageSplitOption;
|
||||
this.autoCloseMenu = this.readingProfile.autoCloseMenu;
|
||||
this.readerMode = this.readingProfile.readerMode;
|
||||
this.layoutMode = this.readingProfile.layoutMode || LayoutMode.Single;
|
||||
this.backgroundColor = this.readingProfile.backgroundColor || '#000000';
|
||||
this.readerService.setOverrideStyles(this.backgroundColor);
|
||||
|
||||
this.generalSettingsForm = this.formBuilder.nonNullable.group({
|
||||
autoCloseMenu: new FormControl(this.autoCloseMenu),
|
||||
pageSplitOption: new FormControl(this.pageSplitOption),
|
||||
fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)),
|
||||
widthSlider: new FormControl(this.readingProfile.widthOverride ?? 'none'),
|
||||
layoutMode: new FormControl(this.layoutMode),
|
||||
darkness: new FormControl(100),
|
||||
emulateBook: new FormControl(this.readingProfile.emulateBook),
|
||||
swipeToPaginate: new FormControl(this.readingProfile.swipeToPaginate)
|
||||
});
|
||||
|
||||
this.readerModeSubject.next(this.readerMode);
|
||||
this.pagingDirectionSubject.next(this.pagingDirection);
|
||||
|
||||
// We need a mergeMap when page changes
|
||||
this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe(
|
||||
map(_ => this.createReaderSettingsUpdate()),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
);
|
||||
|
||||
this.updateForm();
|
||||
|
||||
this.pagingDirection$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(dir => {
|
||||
this.pagingDirection = dir;
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {});
|
||||
|
||||
this.readerMode$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(mode => {
|
||||
this.readerMode = mode;
|
||||
this.disableDoubleRendererIfScreenTooSmall();
|
||||
this.cdRef.markForCheck();
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {});
|
||||
|
||||
this.setupWidthOverrideTriggers();
|
||||
|
||||
this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {
|
||||
|
||||
const changeOccurred = parseInt(val, 10) !== this.layoutMode;
|
||||
this.layoutMode = parseInt(val, 10);
|
||||
|
||||
if (this.layoutMode === LayoutMode.Single) {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(this.readingProfile!.pageSplitOption);
|
||||
this.generalSettingsForm.get('pageSplitOption')?.enable();
|
||||
this.generalSettingsForm.get('widthSlider')?.enable();
|
||||
this.generalSettingsForm.get('fittingOption')?.enable();
|
||||
this.generalSettingsForm.get('emulateBook')?.enable();
|
||||
} else {
|
||||
this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit);
|
||||
this.generalSettingsForm.get('pageSplitOption')?.disable();
|
||||
this.generalSettingsForm.get('widthSlider')?.disable();
|
||||
this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight));
|
||||
this.generalSettingsForm.get('fittingOption')?.disable();
|
||||
this.generalSettingsForm.get('emulateBook')?.enable();
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
// Re-render the current page when we switch layouts
|
||||
if (changeOccurred) {
|
||||
this.setPageNum(this.adjustPagesForDoubleRenderer(this.pageNum));
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
this.generalSettingsForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
|
||||
this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10);
|
||||
|
||||
const needsSplitting = this.mangaReaderService.isWidePage(this.readerService.imageUrlToPageNum(this.canvasImage.src));
|
||||
// If we need to split on a menu change, then we need to re-render.
|
||||
if (needsSplitting) {
|
||||
// If we need to re-render, to ensure things layout properly, let's update paging direction & reset render
|
||||
this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
|
||||
this.canvasRenderer.reset();
|
||||
this.loadPage();
|
||||
}
|
||||
});
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Width override is only valid under the following conditions:
|
||||
* Image Scaling is Width
|
||||
@ -750,7 +809,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
).subscribe();
|
||||
|
||||
// Set the default override to 0
|
||||
widthOverrideControl.setValue(0);
|
||||
//widthOverrideControl.setValue(0);
|
||||
|
||||
//send the current width override value to the label
|
||||
this.widthOverrideLabel$ = this.readerSettings$?.pipe(
|
||||
@ -783,7 +842,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
switchToWebtoonReaderIfPagesLikelyWebtoon() {
|
||||
if (this.readerMode === ReaderMode.Webtoon) return;
|
||||
if (!this.user.preferences.allowAutomaticWebtoonReaderDetection) return;
|
||||
if (!this.readingProfile!.allowAutomaticWebtoonReaderDetection) return;
|
||||
|
||||
if (this.mangaReaderService.shouldBeWebtoonMode()) {
|
||||
this.readerMode = ReaderMode.Webtoon;
|
||||
@ -795,7 +854,9 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
disableDoubleRendererIfScreenTooSmall() {
|
||||
if (window.innerWidth > window.innerHeight) {
|
||||
this.generalSettingsForm.get('layoutMode')?.enable();
|
||||
if (this.generalSettingsForm.get('layoutMode')?.disabled) {
|
||||
this.generalSettingsForm.get('layoutMode')?.enable();
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
@ -1463,7 +1524,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.readingDirection = ReadingDirection.LeftToRight;
|
||||
}
|
||||
|
||||
if (this.menuOpen && this.user.preferences.showScreenHints) {
|
||||
if (this.menuOpen && this.readingProfile!.showScreenHints) {
|
||||
this.showClickOverlay = true;
|
||||
this.showClickOverlaySubject.next(true);
|
||||
setTimeout(() => {
|
||||
@ -1740,28 +1801,28 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
// menu only code
|
||||
savePref() {
|
||||
const modelSettings = this.generalSettingsForm.getRawValue();
|
||||
// Get latest preferences from user, overwrite with what we manage in this UI, then save
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (!user) return;
|
||||
const data = {...user.preferences};
|
||||
data.layoutMode = parseInt(modelSettings.layoutMode, 10);
|
||||
data.readerMode = this.readerMode;
|
||||
data.autoCloseMenu = this.autoCloseMenu;
|
||||
data.readingDirection = this.readingDirection;
|
||||
data.emulateBook = modelSettings.emulateBook;
|
||||
data.swipeToPaginate = modelSettings.swipeToPaginate;
|
||||
data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10);
|
||||
data.locale = data.locale || 'en';
|
||||
updateParentPref() {
|
||||
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accountService.updatePreferences(data).subscribe(updatedPrefs => {
|
||||
this.toastr.success(translate('manga-reader.user-preferences-updated'));
|
||||
if (this.user) {
|
||||
this.user.preferences = updatedPrefs;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
})
|
||||
this.readingProfileService.updateParentProfile(this.seriesId, this.packReadingProfile()).subscribe(newProfile => {
|
||||
this.readingProfile = newProfile;
|
||||
this.toastr.success(translate('manga-reader.reading-profile-updated'));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
createNewProfileFromImplicit() {
|
||||
if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
|
||||
this.readingProfile = newProfile;
|
||||
this.parentReadingProfile = newProfile; // Profile is no longer implicit
|
||||
this.toastr.success(translate("manga-reader.reading-profile-promoted"));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
@ -1771,4 +1832,21 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
return d;
|
||||
}
|
||||
|
||||
private packReadingProfile(): ReadingProfile {
|
||||
const modelSettings = this.generalSettingsForm.getRawValue();
|
||||
const data = {...this.readingProfile!};
|
||||
|
||||
data.layoutMode = parseInt(modelSettings.layoutMode, 10);
|
||||
data.readerMode = this.readerMode;
|
||||
data.autoCloseMenu = this.autoCloseMenu;
|
||||
data.readingDirection = this.readingDirection;
|
||||
data.emulateBook = modelSettings.emulateBook;
|
||||
data.swipeToPaginate = modelSettings.swipeToPaginate;
|
||||
data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10);
|
||||
data.widthOverride = modelSettings.widthSlider === 'none' ? null : modelSettings.widthSlider;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
protected readonly ReadingProfileKind = ReadingProfileKind;
|
||||
}
|
||||
|
@ -34,6 +34,9 @@ import {PdfSpreadMode} from "../../../_models/preferences/pdf-spread-mode";
|
||||
import {SpreadType} from "ngx-extended-pdf-viewer/lib/options/spread-type";
|
||||
import {PdfScrollModeTypePipe} from "../../_pipe/pdf-scroll-mode.pipe";
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-pdf-reader',
|
||||
@ -54,6 +57,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
public readonly accountService = inject(AccountService);
|
||||
private readonly readingProfileService = inject(ReadingProfileService);
|
||||
public readonly readerService = inject(ReaderService);
|
||||
public readonly utilityService = inject(UtilityService);
|
||||
public readonly destroyRef = inject(DestroyRef);
|
||||
@ -69,6 +73,7 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
chapterId!: number;
|
||||
chapter!: Chapter;
|
||||
user!: User;
|
||||
readingProfile!: ReadingProfile;
|
||||
|
||||
/**
|
||||
* Reading List id. Defaults to -1.
|
||||
@ -162,6 +167,16 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
this.chapterId = parseInt(chapterId, 10);
|
||||
this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
|
||||
|
||||
this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
|
||||
this.readingProfile = data['readingProfile'];
|
||||
if (this.readingProfile == null) {
|
||||
this.router.navigateByUrl('/home');
|
||||
return;
|
||||
}
|
||||
this.setupReaderSettings();
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
|
||||
if (readingListId != null) {
|
||||
@ -234,12 +249,14 @@ export class PdfReaderComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
setupReaderSettings() {
|
||||
this.pageLayoutMode = this.convertPdfLayoutMode(PdfLayoutMode.Multiple);
|
||||
this.scrollMode = this.convertPdfScrollMode(this.user.preferences.pdfScrollMode || PdfScrollMode.Vertical);
|
||||
this.spreadMode = this.convertPdfSpreadMode(this.user.preferences.pdfSpreadMode || PdfSpreadMode.None);
|
||||
this.theme = this.convertPdfTheme(this.user.preferences.pdfTheme || PdfTheme.Dark);
|
||||
this.scrollMode = this.convertPdfScrollMode(this.readingProfile.pdfScrollMode || PdfScrollMode.Vertical);
|
||||
this.spreadMode = this.convertPdfSpreadMode(this.readingProfile.pdfSpreadMode || PdfSpreadMode.None);
|
||||
this.theme = this.convertPdfTheme(this.readingProfile.pdfTheme || PdfTheme.Dark);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.backgroundColor = this.themeMap[this.theme].background;
|
||||
this.fontColor = this.themeMap[this.theme].font; // TODO: Move this to an observable or something
|
||||
|
||||
|
@ -110,6 +110,7 @@ import {LicenseService} from "../../../_services/license.service";
|
||||
import {PageBookmark} from "../../../_models/readers/page-bookmark";
|
||||
import {VolumeRemovedEvent} from "../../../_models/events/volume-removed-event";
|
||||
import {ReviewsComponent} from "../../../_single-module/reviews/reviews.component";
|
||||
import {ReadingProfileService} from "../../../_services/reading-profile.service";
|
||||
|
||||
|
||||
enum TabID {
|
||||
@ -175,6 +176,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly readingProfileService = inject(ReadingProfileService);
|
||||
protected readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
protected readonly utilityService = inject(UtilityService);
|
||||
protected readonly imageService = inject(ImageService);
|
||||
@ -609,6 +611,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.actionService.sendToDevice(chapterIds, device);
|
||||
break;
|
||||
}
|
||||
case Action.SetReadingProfile:
|
||||
this.actionService.setReadingProfileForMultiple([this.series]);
|
||||
break;
|
||||
case Action.ClearReadingProfile:
|
||||
this.readingProfileService.clearSeriesProfiles(this.seriesId).subscribe(() => {
|
||||
this.toastr.success(this.translocoService.translate('actionable.cleared-profile'));
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -217,6 +217,15 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.ReadingProfiles; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.ReadingProfiles) {
|
||||
<div class="scale col-md-12">
|
||||
<app-manage-reading-profiles></app-manage-reading-profiles>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -52,43 +52,47 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb
|
||||
import {
|
||||
ManageMetadataSettingsComponent
|
||||
} from "../../../admin/manage-metadata-settings/manage-metadata-settings.component";
|
||||
import {
|
||||
ManageReadingProfilesComponent
|
||||
} from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [
|
||||
ChangeAgeRestrictionComponent,
|
||||
ChangeEmailComponent,
|
||||
ChangePasswordComponent,
|
||||
ManageDevicesComponent,
|
||||
ManageOpdsComponent,
|
||||
ManageScrobblingProvidersComponent,
|
||||
ManageUserPreferencesComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
ThemeManagerComponent,
|
||||
TranslocoDirective,
|
||||
UserStatsComponent,
|
||||
AsyncPipe,
|
||||
LicenseComponent,
|
||||
ManageEmailSettingsComponent,
|
||||
ManageLibraryComponent,
|
||||
ManageMediaSettingsComponent,
|
||||
ManageSettingsComponent,
|
||||
ManageSystemComponent,
|
||||
ManageTasksSettingsComponent,
|
||||
ManageUsersComponent,
|
||||
ServerStatsComponent,
|
||||
SettingFragmentPipe,
|
||||
ManageScrobblingComponent,
|
||||
ManageMediaIssuesComponent,
|
||||
ManageCustomizationComponent,
|
||||
ImportMalCollectionComponent,
|
||||
ImportCblComponent,
|
||||
ManageMatchedMetadataComponent,
|
||||
ManageUserTokensComponent,
|
||||
EmailHistoryComponent,
|
||||
ScrobblingHoldsComponent,
|
||||
ManageMetadataSettingsComponent
|
||||
],
|
||||
imports: [
|
||||
ChangeAgeRestrictionComponent,
|
||||
ChangeEmailComponent,
|
||||
ChangePasswordComponent,
|
||||
ManageDevicesComponent,
|
||||
ManageOpdsComponent,
|
||||
ManageScrobblingProvidersComponent,
|
||||
ManageUserPreferencesComponent,
|
||||
SideNavCompanionBarComponent,
|
||||
ThemeManagerComponent,
|
||||
TranslocoDirective,
|
||||
UserStatsComponent,
|
||||
AsyncPipe,
|
||||
LicenseComponent,
|
||||
ManageEmailSettingsComponent,
|
||||
ManageLibraryComponent,
|
||||
ManageMediaSettingsComponent,
|
||||
ManageSettingsComponent,
|
||||
ManageSystemComponent,
|
||||
ManageTasksSettingsComponent,
|
||||
ManageUsersComponent,
|
||||
ServerStatsComponent,
|
||||
SettingFragmentPipe,
|
||||
ManageScrobblingComponent,
|
||||
ManageMediaIssuesComponent,
|
||||
ManageCustomizationComponent,
|
||||
ImportMalCollectionComponent,
|
||||
ImportCblComponent,
|
||||
ManageMatchedMetadataComponent,
|
||||
ManageUserTokensComponent,
|
||||
EmailHistoryComponent,
|
||||
ScrobblingHoldsComponent,
|
||||
ManageMetadataSettingsComponent,
|
||||
ManageReadingProfilesComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -16,7 +16,7 @@ import {AsyncPipe, NgClass} from "@angular/common";
|
||||
import {SideNavItemComponent} from "../side-nav-item/side-nav-item.component";
|
||||
import {FilterPipe} from "../../../_pipes/filter.pipe";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
||||
@ -25,6 +25,7 @@ import {SettingsTabId} from "../../preference-nav/preference-nav.component";
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
@ -53,7 +54,9 @@ export class SideNavComponent implements OnInit {
|
||||
protected readonly licenseService = inject(LicenseService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly toastr = inject(ToastrService)
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly readingProfilesService = inject(ReadingProfileService);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
|
||||
cachedData: SideNavStream[] | null = null;
|
||||
@ -175,6 +178,14 @@ export class SideNavComponent implements OnInit {
|
||||
case (Action.Edit):
|
||||
this.actionService.editLibrary(lib, () => window.scrollTo(0, 0));
|
||||
break;
|
||||
case (Action.SetReadingProfile):
|
||||
this.actionService.setReadingProfileForLibrary(lib);
|
||||
break;
|
||||
case (Action.ClearReadingProfile):
|
||||
this.readingProfilesService.clearLibraryProfiles(lib.id).subscribe(() => {
|
||||
this.toastr.success(this.translocoService.translate('actionable.cleared-profile'));
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -41,6 +41,7 @@ export enum SettingsTabId {
|
||||
// Non-Admin
|
||||
Account = 'account',
|
||||
Preferences = 'preferences',
|
||||
ReadingProfiles = 'reading-profiles',
|
||||
Clients = 'clients',
|
||||
Theme = 'theme',
|
||||
Devices = 'devices',
|
||||
@ -111,6 +112,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.Account, []),
|
||||
new SideNavItem(SettingsTabId.Preferences),
|
||||
new SideNavItem(SettingsTabId.ReadingProfiles),
|
||||
new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]),
|
||||
new SideNavItem(SettingsTabId.Clients),
|
||||
new SideNavItem(SettingsTabId.Theme),
|
||||
|
@ -0,0 +1,509 @@
|
||||
<ng-container *transloco="let t;prefix:'manage-reading-profiles'">
|
||||
|
||||
<app-loading [loading]="loading"></app-loading>
|
||||
|
||||
@if (!loading) {
|
||||
<div class="position-relative">
|
||||
<button class="btn btn-outline-primary position-absolute custom-position" [ngbTooltip]="t('add-tooltip')" (click)="addNew()" [title]="t('add')">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="ps-2">{{t('description')}}</p>
|
||||
<p class="ps-2 text-muted">{{t('extra-tip')}}</p>
|
||||
|
||||
|
||||
<div class="row g-0 ">
|
||||
|
||||
<div class="col-lg-3 col-md-5 col-sm-7 col-xs-7 scroller">
|
||||
<div class="pe-2">
|
||||
|
||||
@if (readingProfiles.length < virtualScrollerBreakPoint) {
|
||||
@for (readingProfile of readingProfiles; track readingProfile.id) {
|
||||
<ng-container [ngTemplateOutlet]="readingProfileOption" [ngTemplateOutletContext]="{$implicit: readingProfile}"></ng-container>
|
||||
}
|
||||
} @else {
|
||||
<virtual-scroller #scroll [items]="readingProfiles">
|
||||
@for (readingProfile of scroll.viewPortItems; track readingProfile.id) {
|
||||
<ng-container [ngTemplateOutlet]="readingProfileOption" [ngTemplateOutletContext]="{$implicit: readingProfile}"></ng-container>
|
||||
}
|
||||
</virtual-scroller>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9 col-md-7 col-sm-4 col-xs-4 ps-3">
|
||||
<div class="card p-3">
|
||||
@if (selectedProfile === null) {
|
||||
<p class="ps-2">{{t('no-selected')}}</p>
|
||||
<p class="ps-2 text-muted">{{t('selection-tip')}}</p>
|
||||
}
|
||||
|
||||
@if (readingProfileForm !== null && selectedProfile !== null) {
|
||||
|
||||
<form [formGroup]="readingProfileForm">
|
||||
|
||||
<div class="mb-2 d-flex justify-content-between align-items-center">
|
||||
<app-setting-item [title]="''" [showEdit]="false" [canEdit]="selectedProfile.kind !== ReadingProfileKind.Default">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('name')!.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input class="form-control" type="text" formControlName="name" [disabled]="selectedProfile.kind === ReadingProfileKind.Default">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
|
||||
@if (selectedProfile.id !== 0) {
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-danger" (click)="delete(selectedProfile!)" [disabled]="selectedProfile.kind === ReadingProfileKind.Default">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('delete')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="carousel-tabs-container mb-2">
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTabId" class="nav nav-tabs">
|
||||
|
||||
<li [ngbNavItem]="TabId.ImageReader">
|
||||
<a ngbNavLink (click)="activeTabId = TabId.ImageReader">{{t('image-reader-settings-title')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabId.ImageReader; prefetch on idle) {
|
||||
<div>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('reading-direction-label')" [subtitle]="t('reading-direction-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('readingDirection')!.value | readingDirection}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="readingDirection">
|
||||
@for (opt of readingDirections; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | readingDirection}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('scaling-option-label')" [subtitle]="t('scaling-option-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('scalingOption')!.value | scalingOption}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="scalingOption">
|
||||
@for (opt of scalingOptions; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | scalingOption}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('page-splitting-label')" [subtitle]="t('page-splitting-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('pageSplitOption')!.value | pageSplitOption}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="pageSplitOption">
|
||||
@for (opt of pageSplitOptions; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pageSplitOption}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('reading-mode-label')" [subtitle]="t('reading-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('readerMode')!.value | readerMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="readerMode">
|
||||
@for (opt of readerModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | readerMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('layout-mode-label')" [subtitle]="t('layout-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('layoutMode')!.value | layoutMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="layoutMode">
|
||||
@for (opt of layoutModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | layoutMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('background-color-label')" [subtitle]="t('background-color-tooltip')">
|
||||
<ng-template #view>
|
||||
<div class="color-box-container">
|
||||
<div class="color-box" [ngStyle]="{'background-color': selectedProfile!.backgroundColor}"></div>
|
||||
<span class="hex-code">{{ selectedProfile!.backgroundColor.toUpperCase() }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input [value]="selectedProfile!.backgroundColor" class="form-control"
|
||||
(colorPickerChange)="handleBackgroundColorChange($event)"
|
||||
[style.background]="selectedProfile!.backgroundColor" [cpAlphaChannel]="'disabled'"
|
||||
[(colorPicker)]="selectedProfile!.backgroundColor" />
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('auto-close-menu-label')" [subtitle]="t('auto-close-menu-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
formControlName="autoCloseMenu" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('show-screen-hints-label')" [subtitle]="t('show-screen-hints-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
formControlName="showScreenHints" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('emulate-comic-book-label')" [subtitle]="t('emulate-comic-book-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="emulate-comic-book"
|
||||
formControlName="emulateBook" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('swipe-to-paginate-label')" [subtitle]="t('swipe-to-paginate-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="swipe-to-paginate"
|
||||
formControlName="swipeToPaginate" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('allow-auto-webtoon-reader-label')" [subtitle]="t('allow-auto-webtoon-reader-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="allow-auto-webtoon-reader"
|
||||
formControlName="allowAutomaticWebtoonReaderDetection" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
@if (readingProfileForm.get("widthOverride"); as widthOverride) {
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('width-override-label')" [subtitle]="t('width-override-tooltip')">
|
||||
<ng-template #view>
|
||||
<label for="width-override-slider" class="form-label">
|
||||
{{ widthOverrideLabel | sentenceCase }}
|
||||
</label>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>{{ widthOverrideLabel | sentenceCase }}</span>
|
||||
<button class="btn btn-secondary" (click)="widthOverride.setValue(null)">
|
||||
{{t('reset')}}
|
||||
</button>
|
||||
</div>
|
||||
<input id="width-override-slider" type="range" min="0" max="100" class="form-range" formControlName="widthOverride">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabId.BookReader">
|
||||
<a ngbNavLink (click)="activeTabId = TabId.BookReader">{{t('book-reader-settings-title')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabId.BookReader; prefetch on idle) {
|
||||
@if (selectedProfile !== null && readingProfileForm !== null) {
|
||||
<div>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('tap-to-paginate-label')" [subtitle]="t('tap-to-paginate-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="tap-to-paginate"
|
||||
formControlName="bookReaderTapToPaginate" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('immersive-mode-label')" [subtitle]="t('immersive-mode-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
formControlName="bookReaderImmersiveMode" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('reading-direction-label')" [subtitle]="t('reading-direction-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('bookReaderReadingDirection')!.value | readingDirection}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderReadingDirection">
|
||||
@for (opt of readingDirections; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | readingDirection}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('font-family-label')" [subtitle]="t('font-family-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('bookReaderFontFamily')!.value | titlecase}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderFontFamily">
|
||||
@for (opt of fontFamilies; track opt) {
|
||||
<option [value]="opt">{{opt | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('writing-style-label')" [subtitle]="t('writing-style-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('bookReaderWritingStyle')!.value | writingStyle}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderWritingStyle">
|
||||
@for (opt of bookWritingStyles; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | writingStyle}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('layout-mode-book-label')" [subtitle]="t('layout-mode-book-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('bookReaderLayoutMode')!.value | bookPageLayoutMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderLayoutMode">
|
||||
@for (opt of bookLayoutModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | bookPageLayoutMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('color-theme-book-label')" [subtitle]="t('color-theme-book-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm.get('bookReaderThemeName')!.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderThemeName">
|
||||
@for (opt of bookColorThemesTranslated; track opt) {
|
||||
<option [value]="opt.name">{{opt.name | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('font-size-book-label')" [subtitle]="t('font-size-book-tooltip')">
|
||||
<ng-template #view>
|
||||
<span class="range-text">{{readingProfileForm.get('bookReaderFontSize')?.value + '%'}}</span>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<input type="range" class="form-range" id="fontsize" min="50" max="300" step="10"
|
||||
formControlName="bookReaderFontSize">
|
||||
|
||||
</div>
|
||||
<span class="ps-2 col-2 align-middle">{{readingProfileForm.get('bookReaderFontSize')?.value + '%'}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('line-height-book-label')" [subtitle]="t('line-height-book-tooltip')">
|
||||
<ng-template #view>
|
||||
<span class="range-text">{{readingProfileForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<input type="range" class="form-range" id="linespacing" min="100" max="200" step="10"
|
||||
formControlName="bookReaderLineSpacing">
|
||||
</div>
|
||||
<span class="ps-2 col-2 align-middle">{{readingProfileForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('margin-book-label')" [subtitle]="t('margin-book-tooltip')">
|
||||
<ng-template #view>
|
||||
<span class="range-text">{{readingProfileForm.get('bookReaderMargin')?.value + '%'}}</span>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<input type="range" class="form-range" id="margin" min="0" max="30" step="5"
|
||||
formControlName="bookReaderMargin">
|
||||
</div>
|
||||
<span class="ps-2 col-2 align-middle">{{readingProfileForm!.get('bookReaderMargin')?.value + '%'}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="TabId.PdfReader">
|
||||
<a ngbNavLink (click)="activeTabId = TabId.PdfReader">{{t('pdf-reader-settings-title')}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
@defer (when activeTabId === TabId.PdfReader; prefetch on idle) {
|
||||
<div>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('pdf-spread-mode-label')" [subtitle]="t('pdf-spread-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm!.get('pdfSpreadMode')!.value | pdfSpreadMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="pdf-reader-heading"
|
||||
formControlName="pdfSpreadMode">
|
||||
@for (opt of pdfSpreadModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pdfSpreadMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('pdf-theme-label')" [subtitle]="t('pdf-theme-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm!.get('pdfTheme')!.value | pdfTheme}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="pdf-reader-heading"
|
||||
formControlName="pdfTheme">
|
||||
@for (opt of pdfThemes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pdfTheme}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('pdf-scroll-mode-label')" [subtitle]="t('pdf-scroll-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{readingProfileForm!.get('pdfScrollMode')!.value | pdfScrollMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="pdf-reader-heading"
|
||||
formControlName="pdfScrollMode">
|
||||
@for (opt of pdfScrollModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pdfScrollMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<ng-template #readingProfileOption let-profile>
|
||||
|
||||
<div class="p-2 group-item d-flex justify-content-between align-items-start {{selectedProfile && profile.id === selectedProfile!.id ? 'active' : ''}}"
|
||||
(click)="selectProfile(profile)">
|
||||
|
||||
<div class="fw-bold">{{profile.name | sentenceCase}}</div>
|
||||
|
||||
@if (profile.kind === ReadingProfileKind.Default) {
|
||||
<span class="pill p-1 ms-1">{{t('default-profile')}}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,38 @@
|
||||
@use '../../../series-detail-common';
|
||||
|
||||
|
||||
|
||||
.group-item {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active, &.active {
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.pill {
|
||||
font-size: .8rem;
|
||||
background-color: var(--card-bg-color);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--badge-text-color);
|
||||
|
||||
&.active {
|
||||
background-color : var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 15px;
|
||||
top: -42px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
cursor: pointer;
|
||||
}
|
@ -0,0 +1,319 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, OnInit} from '@angular/core';
|
||||
import {ReadingProfileService} from "../../_services/reading-profile.service";
|
||||
import {
|
||||
bookLayoutModes,
|
||||
bookWritingStyles,
|
||||
layoutModes,
|
||||
pageSplitOptions,
|
||||
pdfScrollModes,
|
||||
pdfSpreadModes,
|
||||
pdfThemes,
|
||||
readingDirections,
|
||||
readingModes,
|
||||
ReadingProfile,
|
||||
ReadingProfileKind,
|
||||
scalingOptions
|
||||
} from "../../_models/preferences/reading-profiles";
|
||||
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
|
||||
import {NgStyle, NgTemplateOutlet, TitleCasePipe} from "@angular/common";
|
||||
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
|
||||
import {User} from "../../_models/user";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {debounceTime, distinctUntilChanged, take, tap} from "rxjs/operators";
|
||||
import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {BookService} from "../../book-reader/_services/book.service";
|
||||
import {BookPageLayoutMode} from "../../_models/readers/book-page-layout-mode";
|
||||
import {PdfTheme} from "../../_models/preferences/pdf-theme";
|
||||
import {PdfScrollMode} from "../../_models/preferences/pdf-scroll-mode";
|
||||
import {PdfSpreadMode} from "../../_models/preferences/pdf-spread-mode";
|
||||
import {bookColorThemes} from "../../book-reader/_components/reader-settings/reader-settings.component";
|
||||
import {BookPageLayoutModePipe} from "../../_pipes/book-page-layout-mode.pipe";
|
||||
import {LayoutModePipe} from "../../_pipes/layout-mode.pipe";
|
||||
import {PageSplitOptionPipe} from "../../_pipes/page-split-option.pipe";
|
||||
import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe";
|
||||
import {PdfSpreadModePipe} from "../../_pipes/pdf-spread-mode.pipe";
|
||||
import {PdfThemePipe} from "../../_pipes/pdf-theme.pipe";
|
||||
import {ReaderModePipe} from "../../_pipes/reading-mode.pipe";
|
||||
import {ReadingDirectionPipe} from "../../_pipes/reading-direction.pipe";
|
||||
import {ScalingOptionPipe} from "../../_pipes/scaling-option.pipe";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {WritingStylePipe} from "../../_pipes/writing-style.pipe";
|
||||
import {ColorPickerDirective} from "ngx-color-picker";
|
||||
import {NgbNav, NgbNavContent, NgbNavItem, NgbNavLinkBase, NgbNavOutlet, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {filter} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
|
||||
enum TabId {
|
||||
ImageReader = "image-reader",
|
||||
BookReader = "book-reader",
|
||||
PdfReader = "pdf-reader",
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-reading-profiles',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
NgTemplateOutlet,
|
||||
VirtualScrollerModule,
|
||||
SentenceCasePipe,
|
||||
BookPageLayoutModePipe,
|
||||
FormsModule,
|
||||
LayoutModePipe,
|
||||
PageSplitOptionPipe,
|
||||
PdfScrollModePipe,
|
||||
PdfSpreadModePipe,
|
||||
PdfThemePipe,
|
||||
ReactiveFormsModule,
|
||||
ReaderModePipe,
|
||||
ReadingDirectionPipe,
|
||||
ScalingOptionPipe,
|
||||
SettingItemComponent,
|
||||
SettingSwitchComponent,
|
||||
TitleCasePipe,
|
||||
WritingStylePipe,
|
||||
NgStyle,
|
||||
ColorPickerDirective,
|
||||
NgbNav,
|
||||
NgbNavItem,
|
||||
NgbNavLinkBase,
|
||||
NgbNavContent,
|
||||
NgbNavOutlet,
|
||||
LoadingComponent,
|
||||
NgbTooltip,
|
||||
],
|
||||
templateUrl: './manage-reading-profiles.component.html',
|
||||
styleUrl: './manage-reading-profiles.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ManageReadingProfilesComponent implements OnInit {
|
||||
|
||||
virtualScrollerBreakPoint = 20;
|
||||
|
||||
fontFamilies: Array<string> = [];
|
||||
readingProfiles: ReadingProfile[] = [];
|
||||
user!: User;
|
||||
activeTabId = TabId.ImageReader;
|
||||
loading = true;
|
||||
|
||||
selectedProfile: ReadingProfile | null = null;
|
||||
readingProfileForm: FormGroup | null = null;
|
||||
bookColorThemesTranslated = bookColorThemes.map(o => {
|
||||
const d = {...o};
|
||||
d.name = translate('theme.' + d.translationKey);
|
||||
return d;
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readingProfileService: ReadingProfileService,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private accountService: AccountService,
|
||||
private bookService: BookService,
|
||||
private destroyRef: DestroyRef,
|
||||
private toastr: ToastrService,
|
||||
private confirmService: ConfirmService,
|
||||
private transLoco: TranslocoService,
|
||||
) {
|
||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
}
|
||||
});
|
||||
|
||||
this.readingProfileService.getAllProfiles().subscribe(profiles => {
|
||||
this.readingProfiles = profiles;
|
||||
this.loading = false;
|
||||
this.setupForm();
|
||||
|
||||
const defaultProfile = this.readingProfiles.find(rp => rp.kind === ReadingProfileKind.Default);
|
||||
this.selectProfile(defaultProfile);
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
async delete(readingProfile: ReadingProfile) {
|
||||
if (!await this.confirmService.confirm(this.transLoco.translate("manage-reading-profiles.confirm", {name: readingProfile.name}))) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.readingProfileService.delete(readingProfile.id).subscribe(() => {
|
||||
this.selectProfile(undefined);
|
||||
this.readingProfiles = this.readingProfiles.filter(o => o.id !== readingProfile.id);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
get widthOverrideLabel() {
|
||||
const rawVal = this.readingProfileForm?.get('widthOverride')!.value;
|
||||
if (!rawVal) {
|
||||
return translate('reader-settings.off');
|
||||
}
|
||||
|
||||
const val = parseInt(rawVal);
|
||||
return (val <= 0) ? '' : val + '%'
|
||||
}
|
||||
|
||||
setupForm() {
|
||||
if (this.selectedProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.readingProfileForm = new FormGroup({})
|
||||
|
||||
if (this.fontFamilies.indexOf(this.selectedProfile.bookReaderFontFamily) < 0) {
|
||||
this.selectedProfile.bookReaderFontFamily = 'default';
|
||||
}
|
||||
|
||||
this.readingProfileForm.addControl('name', new FormControl(this.selectedProfile.name, Validators.required));
|
||||
|
||||
|
||||
// Image reader
|
||||
this.readingProfileForm.addControl('readingDirection', new FormControl(this.selectedProfile.readingDirection, []));
|
||||
this.readingProfileForm.addControl('scalingOption', new FormControl(this.selectedProfile.scalingOption, []));
|
||||
this.readingProfileForm.addControl('pageSplitOption', new FormControl(this.selectedProfile.pageSplitOption, []));
|
||||
this.readingProfileForm.addControl('autoCloseMenu', new FormControl(this.selectedProfile.autoCloseMenu, []));
|
||||
this.readingProfileForm.addControl('showScreenHints', new FormControl(this.selectedProfile.showScreenHints, []));
|
||||
this.readingProfileForm.addControl('readerMode', new FormControl(this.selectedProfile.readerMode, []));
|
||||
this.readingProfileForm.addControl('layoutMode', new FormControl(this.selectedProfile.layoutMode, []));
|
||||
this.readingProfileForm.addControl('emulateBook', new FormControl(this.selectedProfile.emulateBook, []));
|
||||
this.readingProfileForm.addControl('swipeToPaginate', new FormControl(this.selectedProfile.swipeToPaginate, []));
|
||||
this.readingProfileForm.addControl('backgroundColor', new FormControl(this.selectedProfile.backgroundColor, []));
|
||||
this.readingProfileForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.selectedProfile.allowAutomaticWebtoonReaderDetection, []));
|
||||
this.readingProfileForm.addControl('widthOverride', new FormControl(this.selectedProfile.widthOverride, [Validators.min(0), Validators.max(100)]));
|
||||
|
||||
// Epub reader
|
||||
this.readingProfileForm.addControl('bookReaderFontFamily', new FormControl(this.selectedProfile.bookReaderFontFamily, []));
|
||||
this.readingProfileForm.addControl('bookReaderFontSize', new FormControl(this.selectedProfile.bookReaderFontSize, []));
|
||||
this.readingProfileForm.addControl('bookReaderLineSpacing', new FormControl(this.selectedProfile.bookReaderLineSpacing, []));
|
||||
this.readingProfileForm.addControl('bookReaderMargin', new FormControl(this.selectedProfile.bookReaderMargin, []));
|
||||
this.readingProfileForm.addControl('bookReaderReadingDirection', new FormControl(this.selectedProfile.bookReaderReadingDirection, []));
|
||||
this.readingProfileForm.addControl('bookReaderWritingStyle', new FormControl(this.selectedProfile.bookReaderWritingStyle, []))
|
||||
this.readingProfileForm.addControl('bookReaderTapToPaginate', new FormControl(this.selectedProfile.bookReaderTapToPaginate, []));
|
||||
this.readingProfileForm.addControl('bookReaderLayoutMode', new FormControl(this.selectedProfile.bookReaderLayoutMode || BookPageLayoutMode.Default, []));
|
||||
this.readingProfileForm.addControl('bookReaderThemeName', new FormControl(this.selectedProfile.bookReaderThemeName || bookColorThemes[0].name, []));
|
||||
this.readingProfileForm.addControl('bookReaderImmersiveMode', new FormControl(this.selectedProfile.bookReaderImmersiveMode, []));
|
||||
|
||||
// Pdf reader
|
||||
this.readingProfileForm.addControl('pdfTheme', new FormControl(this.selectedProfile.pdfTheme || PdfTheme.Dark, []));
|
||||
this.readingProfileForm.addControl('pdfScrollMode', new FormControl(this.selectedProfile.pdfScrollMode || PdfScrollMode.Vertical, []));
|
||||
this.readingProfileForm.addControl('pdfSpreadMode', new FormControl(this.selectedProfile.pdfSpreadMode || PdfSpreadMode.None, []));
|
||||
|
||||
// Auto save
|
||||
this.readingProfileForm.valueChanges.pipe(
|
||||
debounceTime(500),
|
||||
distinctUntilChanged(),
|
||||
filter(_ => this.readingProfileForm!.valid),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap(_ => this.autoSave()),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
private autoSave() {
|
||||
if (this.selectedProfile!.id == 0) {
|
||||
this.readingProfileService.createProfile(this.packData()).subscribe({
|
||||
next: createdProfile => {
|
||||
this.selectedProfile = createdProfile;
|
||||
this.readingProfiles.push(createdProfile);
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.log(err);
|
||||
this.toastr.error(err.message);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const profile = this.packData();
|
||||
this.readingProfileService.updateProfile(profile).subscribe({
|
||||
next: _ => {
|
||||
this.readingProfiles = this.readingProfiles.map(p => {
|
||||
if (p.id !== profile.id) return p;
|
||||
return profile;
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: err => {
|
||||
console.log(err);
|
||||
this.toastr.error(err.message);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private packData(): ReadingProfile {
|
||||
const data: ReadingProfile = this.readingProfileForm!.getRawValue();
|
||||
data.id = this.selectedProfile!.id;
|
||||
data.readingDirection = parseInt(data.readingDirection as unknown as string);
|
||||
data.scalingOption = parseInt(data.scalingOption as unknown as string);
|
||||
data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string);
|
||||
data.readerMode = parseInt(data.readerMode as unknown as string);
|
||||
data.layoutMode = parseInt(data.layoutMode as unknown as string);
|
||||
|
||||
data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string);
|
||||
data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string);
|
||||
data.bookReaderLayoutMode = parseInt(data.bookReaderLayoutMode as unknown as string);
|
||||
|
||||
data.pdfTheme = parseInt(data.pdfTheme as unknown as string);
|
||||
data.pdfScrollMode = parseInt(data.pdfScrollMode as unknown as string);
|
||||
data.pdfSpreadMode = parseInt(data.pdfSpreadMode as unknown as string);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
handleBackgroundColorChange(color: string) {
|
||||
if (!this.readingProfileForm || !this.selectedProfile) return;
|
||||
|
||||
this.readingProfileForm.markAsDirty();
|
||||
this.readingProfileForm.markAsTouched();
|
||||
this.selectedProfile.backgroundColor = color;
|
||||
this.readingProfileForm.get('backgroundColor')?.setValue(color);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
selectProfile(profile: ReadingProfile | undefined | null) {
|
||||
if (profile === undefined) {
|
||||
this.selectedProfile = null;
|
||||
this.cdRef.markForCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedProfile = profile;
|
||||
this.setupForm();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
addNew() {
|
||||
const defaultProfile = this.readingProfiles.find(f => f.kind === ReadingProfileKind.Default);
|
||||
this.selectedProfile = {...defaultProfile!};
|
||||
this.selectedProfile.kind = ReadingProfileKind.User;
|
||||
this.selectedProfile.id = 0;
|
||||
this.selectedProfile.name = "New Profile #" + (this.readingProfiles.length + 1);
|
||||
this.setupForm();
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
protected readonly readingDirections = readingDirections;
|
||||
protected readonly pdfSpreadModes = pdfSpreadModes;
|
||||
protected readonly pageSplitOptions = pageSplitOptions;
|
||||
protected readonly bookLayoutModes = bookLayoutModes;
|
||||
protected readonly pdfThemes = pdfThemes;
|
||||
protected readonly scalingOptions = scalingOptions;
|
||||
protected readonly layoutModes = layoutModes;
|
||||
protected readonly readerModes = readingModes;
|
||||
protected readonly bookWritingStyles = bookWritingStyles;
|
||||
protected readonly pdfScrollModes = pdfScrollModes;
|
||||
protected readonly TabId = TabId;
|
||||
protected readonly ReadingProfileKind = ReadingProfileKind;
|
||||
}
|
@ -115,383 +115,7 @@
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
<div class="setting-section-break"></div>
|
||||
}
|
||||
|
||||
|
||||
<h4 id="image-reader-heading" class="mt-3">{{t('image-reader-settings-title')}}</h4>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('reading-direction-label')" [subtitle]="t('reading-direction-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('readingDirection')!.value | readingDirection}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="readingDirection">
|
||||
@for (opt of readingDirections; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | readingDirection}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('scaling-option-label')" [subtitle]="t('scaling-option-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('scalingOption')!.value | scalingOption}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="scalingOption">
|
||||
@for (opt of scalingOptions; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | scalingOption}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('page-splitting-label')" [subtitle]="t('page-splitting-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('pageSplitOption')!.value | pageSplitOption}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="pageSplitOption">
|
||||
@for (opt of pageSplitOptions; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pageSplitOption}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('reading-mode-label')" [subtitle]="t('reading-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('readerMode')!.value | readerMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="readerMode">
|
||||
@for (opt of readerModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | readerMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('layout-mode-label')" [subtitle]="t('layout-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('layoutMode')!.value | layoutMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="image-reader-heading"
|
||||
formControlName="layoutMode">
|
||||
@for (opt of layoutModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | layoutMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('background-color-label')" [subtitle]="t('background-color-tooltip')">
|
||||
<ng-template #view>
|
||||
<div class="color-box-container">
|
||||
<div class="color-box" [ngStyle]="{'background-color': user.preferences!.backgroundColor}"></div>
|
||||
<span class="hex-code">{{ user.preferences!.backgroundColor.toUpperCase() }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input [value]="user!.preferences!.backgroundColor" class="form-control"
|
||||
(colorPickerChange)="handleBackgroundColorChange($event)"
|
||||
[style.background]="user!.preferences!.backgroundColor" [cpAlphaChannel]="'disabled'"
|
||||
[(colorPicker)]="user!.preferences!.backgroundColor" />
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('auto-close-menu-label')" [subtitle]="t('auto-close-menu-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
formControlName="autoCloseMenu" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('show-screen-hints-label')" [subtitle]="t('show-screen-hints-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
formControlName="showScreenHints" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('emulate-comic-book-label')" [subtitle]="t('emulate-comic-book-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="emulate-comic-book"
|
||||
formControlName="emulateBook" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('swipe-to-paginate-label')" [subtitle]="t('swipe-to-paginate-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="swipe-to-paginate"
|
||||
formControlName="swipeToPaginate" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('allow-auto-webtoon-reader-label')" [subtitle]="t('allow-auto-webtoon-reader-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="allow-auto-webtoon-reader"
|
||||
formControlName="allowAutomaticWebtoonReaderDetection" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4 id="book-reader-heading" class="mt-3">{{t('book-reader-settings-title')}}</h4>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('tap-to-paginate-label')" [subtitle]="t('tap-to-paginate-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch" id="tap-to-paginate"
|
||||
formControlName="bookReaderTapToPaginate" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-switch [title]="t('immersive-mode-label')" [subtitle]="t('immersive-mode-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input type="checkbox" role="switch"
|
||||
formControlName="bookReaderImmersiveMode" class="form-check-input"
|
||||
aria-labelledby="auto-close-label">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('reading-direction-label')" [subtitle]="t('reading-direction-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('bookReaderReadingDirection')!.value | readingDirection}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderReadingDirection">
|
||||
@for (opt of readingDirections; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | readingDirection}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('font-family-label')" [subtitle]="t('font-family-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('bookReaderFontFamily')!.value | titlecase}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderFontFamily">
|
||||
@for (opt of fontFamilies; track opt) {
|
||||
<option [value]="opt">{{opt | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('writing-style-label')" [subtitle]="t('writing-style-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('bookReaderWritingStyle')!.value | writingStyle}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderWritingStyle">
|
||||
@for (opt of bookWritingStyles; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | writingStyle}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('layout-mode-book-label')" [subtitle]="t('layout-mode-book-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('bookReaderLayoutMode')!.value | bookPageLayoutMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderLayoutMode">
|
||||
@for (opt of bookLayoutModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | bookPageLayoutMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('color-theme-book-label')" [subtitle]="t('color-theme-book-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('bookReaderThemeName')!.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="book-reader-heading"
|
||||
formControlName="bookReaderThemeName">
|
||||
@for (opt of bookColorThemesTranslated; track opt) {
|
||||
<option [value]="opt.name">{{opt.name | titlecase}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('font-size-book-label')" [subtitle]="t('font-size-book-tooltip')">
|
||||
<ng-template #view>
|
||||
<span class="range-text">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<input type="range" class="form-range" id="fontsize" min="50" max="300" step="10"
|
||||
formControlName="bookReaderFontSize">
|
||||
|
||||
</div>
|
||||
<span class="ps-2 col-2 align-middle">{{settingsForm.get('bookReaderFontSize')?.value + '%'}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('line-height-book-label')" [subtitle]="t('line-height-book-tooltip')">
|
||||
<ng-template #view>
|
||||
<span class="range-text">{{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<input type="range" class="form-range" id="linespacing" min="100" max="200" step="10"
|
||||
formControlName="bookReaderLineSpacing">
|
||||
</div>
|
||||
<span class="ps-2 col-2 align-middle">{{settingsForm.get('bookReaderLineSpacing')?.value + '%'}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('margin-book-label')" [subtitle]="t('margin-book-tooltip')">
|
||||
<ng-template #view>
|
||||
<span class="range-text">{{settingsForm.get('bookReaderMargin')?.value + '%'}}</span>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<div class="row g-0">
|
||||
<div class="col-10">
|
||||
<input type="range" class="form-range" id="margin" min="0" max="30" step="5"
|
||||
formControlName="bookReaderMargin">
|
||||
</div>
|
||||
<span class="ps-2 col-2 align-middle">{{settingsForm.get('bookReaderMargin')?.value + '%'}}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4 id="pdf-reader-heading" class="mt-3">{{t('pdf-reader-settings-title')}}</h4>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('pdf-spread-mode-label')" [subtitle]="t('pdf-spread-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('pdfSpreadMode')!.value | pdfSpreadMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="pdf-reader-heading"
|
||||
formControlName="pdfSpreadMode">
|
||||
@for (opt of pdfSpreadModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pdfSpreadMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('pdf-theme-label')" [subtitle]="t('pdf-theme-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('pdfTheme')!.value | pdfTheme}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="pdf-reader-heading"
|
||||
formControlName="pdfTheme">
|
||||
@for (opt of pdfThemes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pdfTheme}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<app-setting-item [title]="t('pdf-scroll-mode-label')" [subtitle]="t('pdf-scroll-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
{{settingsForm.get('pdfScrollMode')!.value | pdfScrollMode}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" aria-describedby="pdf-reader-heading"
|
||||
formControlName="pdfScrollMode">
|
||||
@for (opt of pdfScrollModes; track opt) {
|
||||
<option [value]="opt.value">{{opt.value | pdfScrollMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
</div>
|
||||
</ng-container>
|
||||
</form>
|
||||
}
|
||||
|
||||
|
@ -1,17 +1,7 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {
|
||||
bookLayoutModes,
|
||||
bookWritingStyles,
|
||||
layoutModes,
|
||||
pageSplitOptions,
|
||||
pdfScrollModes,
|
||||
pdfSpreadModes,
|
||||
pdfThemes,
|
||||
Preferences,
|
||||
readingDirections,
|
||||
readingModes,
|
||||
scalingOptions
|
||||
Preferences
|
||||
} from "../../_models/preferences/preferences";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {BookService} from "../../book-reader/_services/book.service";
|
||||
@ -44,6 +34,13 @@ import {PdfThemePipe} from "../../_pipes/pdf-theme.pipe";
|
||||
import {PdfScrollModePipe} from "../../_pipes/pdf-scroll-mode.pipe";
|
||||
import {LicenseService} from "../../_services/license.service";
|
||||
import {ColorPickerDirective} from "ngx-color-picker";
|
||||
import {
|
||||
bookLayoutModes, bookWritingStyles,
|
||||
layoutModes, pageSplitOptions,
|
||||
pdfScrollModes,
|
||||
pdfSpreadModes,
|
||||
pdfThemes, readingDirections, readingModes, scalingOptions
|
||||
} from "../../_models/preferences/reading-profiles";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manga-user-preferences',
|
||||
@ -83,23 +80,6 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
private readonly localizationService = inject(LocalizationService);
|
||||
protected readonly licenseService = inject(LicenseService);
|
||||
|
||||
protected readonly readingDirections = readingDirections;
|
||||
protected readonly scalingOptions = scalingOptions;
|
||||
protected readonly pageSplitOptions = pageSplitOptions;
|
||||
protected readonly readerModes = readingModes;
|
||||
protected readonly layoutModes = layoutModes;
|
||||
protected readonly bookWritingStyles = bookWritingStyles;
|
||||
protected readonly bookLayoutModes = bookLayoutModes;
|
||||
protected readonly pdfSpreadModes = pdfSpreadModes;
|
||||
protected readonly pdfThemes = pdfThemes;
|
||||
protected readonly pdfScrollModes = pdfScrollModes;
|
||||
|
||||
bookColorThemesTranslated = bookColorThemes.map(o => {
|
||||
const d = {...o};
|
||||
d.name = translate('theme.' + d.translationKey);
|
||||
return d;
|
||||
});
|
||||
|
||||
|
||||
fontFamilies: Array<string> = [];
|
||||
locales: Array<KavitaLocale> = [];
|
||||
@ -145,37 +125,6 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
this.user = results.user;
|
||||
this.user.preferences = results.pref;
|
||||
|
||||
if (this.fontFamilies.indexOf(this.user.preferences.bookReaderFontFamily) < 0) {
|
||||
this.user.preferences.bookReaderFontFamily = 'default';
|
||||
}
|
||||
|
||||
this.settingsForm.addControl('readingDirection', new FormControl(this.user.preferences.readingDirection, []));
|
||||
this.settingsForm.addControl('scalingOption', new FormControl(this.user.preferences.scalingOption, []));
|
||||
this.settingsForm.addControl('pageSplitOption', new FormControl(this.user.preferences.pageSplitOption, []));
|
||||
this.settingsForm.addControl('autoCloseMenu', new FormControl(this.user.preferences.autoCloseMenu, []));
|
||||
this.settingsForm.addControl('showScreenHints', new FormControl(this.user.preferences.showScreenHints, []));
|
||||
this.settingsForm.addControl('readerMode', new FormControl(this.user.preferences.readerMode, []));
|
||||
this.settingsForm.addControl('layoutMode', new FormControl(this.user.preferences.layoutMode, []));
|
||||
this.settingsForm.addControl('emulateBook', new FormControl(this.user.preferences.emulateBook, []));
|
||||
this.settingsForm.addControl('swipeToPaginate', new FormControl(this.user.preferences.swipeToPaginate, []));
|
||||
this.settingsForm.addControl('backgroundColor', new FormControl(this.user.preferences.backgroundColor, []));
|
||||
this.settingsForm.addControl('allowAutomaticWebtoonReaderDetection', new FormControl(this.user.preferences.allowAutomaticWebtoonReaderDetection, []));
|
||||
|
||||
this.settingsForm.addControl('bookReaderFontFamily', new FormControl(this.user.preferences.bookReaderFontFamily, []));
|
||||
this.settingsForm.addControl('bookReaderFontSize', new FormControl(this.user.preferences.bookReaderFontSize, []));
|
||||
this.settingsForm.addControl('bookReaderLineSpacing', new FormControl(this.user.preferences.bookReaderLineSpacing, []));
|
||||
this.settingsForm.addControl('bookReaderMargin', new FormControl(this.user.preferences.bookReaderMargin, []));
|
||||
this.settingsForm.addControl('bookReaderReadingDirection', new FormControl(this.user.preferences.bookReaderReadingDirection, []));
|
||||
this.settingsForm.addControl('bookReaderWritingStyle', new FormControl(this.user.preferences.bookReaderWritingStyle, []))
|
||||
this.settingsForm.addControl('bookReaderTapToPaginate', new FormControl(this.user.preferences.bookReaderTapToPaginate, []));
|
||||
this.settingsForm.addControl('bookReaderLayoutMode', new FormControl(this.user.preferences.bookReaderLayoutMode || BookPageLayoutMode.Default, []));
|
||||
this.settingsForm.addControl('bookReaderThemeName', new FormControl(this.user?.preferences.bookReaderThemeName || bookColorThemes[0].name, []));
|
||||
this.settingsForm.addControl('bookReaderImmersiveMode', new FormControl(this.user?.preferences.bookReaderImmersiveMode, []));
|
||||
|
||||
this.settingsForm.addControl('pdfTheme', new FormControl(this.user?.preferences.pdfTheme || PdfTheme.Dark, []));
|
||||
this.settingsForm.addControl('pdfScrollMode', new FormControl(this.user?.preferences.pdfScrollMode || PdfScrollMode.Vertical, []));
|
||||
this.settingsForm.addControl('pdfSpreadMode', new FormControl(this.user?.preferences.pdfSpreadMode || PdfSpreadMode.None, []));
|
||||
|
||||
this.settingsForm.addControl('theme', new FormControl(this.user.preferences.theme, []));
|
||||
this.settingsForm.addControl('globalPageLayoutMode', new FormControl(this.user.preferences.globalPageLayoutMode, []));
|
||||
this.settingsForm.addControl('blurUnreadSummaries', new FormControl(this.user.preferences.blurUnreadSummaries, []));
|
||||
@ -222,7 +171,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
reset() {
|
||||
if (!this.user) return;
|
||||
|
||||
this.settingsForm.get('readingDirection')?.setValue(this.user.preferences.readingDirection, {onlySelf: true, emitEvent: false});
|
||||
/*this.settingsForm.get('readingDirection')?.setValue(this.user.preferences.readingDirection, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('scalingOption')?.setValue(this.user.preferences.scalingOption, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('pageSplitOption')?.setValue(this.user.preferences.pageSplitOption, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('autoCloseMenu')?.setValue(this.user.preferences.autoCloseMenu, {onlySelf: true, emitEvent: false});
|
||||
@ -247,7 +196,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
|
||||
this.settingsForm.get('pdfTheme')?.setValue(this.user?.preferences.pdfTheme || PdfTheme.Dark, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('pdfScrollMode')?.setValue(this.user?.preferences.pdfScrollMode || PdfScrollMode.Vertical, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('pdfSpreadMode')?.setValue(this.user?.preferences.pdfSpreadMode || PdfSpreadMode.None, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('pdfSpreadMode')?.setValue(this.user?.preferences.pdfSpreadMode || PdfSpreadMode.None, {onlySelf: true, emitEvent: false});*/
|
||||
|
||||
this.settingsForm.get('theme')?.setValue(this.user.preferences.theme, {onlySelf: true, emitEvent: false});
|
||||
this.settingsForm.get('globalPageLayoutMode')?.setValue(this.user.preferences.globalPageLayoutMode, {onlySelf: true, emitEvent: false});
|
||||
@ -265,7 +214,7 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
packSettings(): Preferences {
|
||||
const modelSettings = this.settingsForm.value;
|
||||
return {
|
||||
readingDirection: parseInt(modelSettings.readingDirection, 10),
|
||||
/*readingDirection: parseInt(modelSettings.readingDirection, 10),
|
||||
scalingOption: parseInt(modelSettings.scalingOption, 10),
|
||||
pageSplitOption: parseInt(modelSettings.pageSplitOption, 10),
|
||||
autoCloseMenu: modelSettings.autoCloseMenu,
|
||||
@ -282,34 +231,23 @@ export class ManageUserPreferencesComponent implements OnInit {
|
||||
bookReaderReadingDirection: parseInt(modelSettings.bookReaderReadingDirection, 10),
|
||||
bookReaderWritingStyle: parseInt(modelSettings.bookReaderWritingStyle, 10),
|
||||
bookReaderLayoutMode: parseInt(modelSettings.bookReaderLayoutMode, 10),
|
||||
bookReaderThemeName: modelSettings.bookReaderThemeName,
|
||||
bookReaderThemeName: modelSettings.bookReaderThemeName,*/
|
||||
theme: modelSettings.theme,
|
||||
bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode,
|
||||
//bookReaderImmersiveMode: modelSettings.bookReaderImmersiveMode,
|
||||
globalPageLayoutMode: parseInt(modelSettings.globalPageLayoutMode, 10),
|
||||
blurUnreadSummaries: modelSettings.blurUnreadSummaries,
|
||||
promptForDownloadSize: modelSettings.promptForDownloadSize,
|
||||
noTransitions: modelSettings.noTransitions,
|
||||
emulateBook: modelSettings.emulateBook,
|
||||
swipeToPaginate: modelSettings.swipeToPaginate,
|
||||
//emulateBook: modelSettings.emulateBook,
|
||||
//swipeToPaginate: modelSettings.swipeToPaginate,
|
||||
collapseSeriesRelationships: modelSettings.collapseSeriesRelationships,
|
||||
shareReviews: modelSettings.shareReviews,
|
||||
locale: modelSettings.locale || 'en',
|
||||
pdfTheme: parseInt(modelSettings.pdfTheme, 10),
|
||||
pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10),
|
||||
pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10),
|
||||
//pdfTheme: parseInt(modelSettings.pdfTheme, 10),
|
||||
//pdfScrollMode: parseInt(modelSettings.pdfScrollMode, 10),
|
||||
//pdfSpreadMode: parseInt(modelSettings.pdfSpreadMode, 10),
|
||||
aniListScrobblingEnabled: modelSettings.aniListScrobblingEnabled,
|
||||
wantToReadSync: modelSettings.wantToReadSync
|
||||
wantToReadSync: modelSettings.wantToReadSync,
|
||||
};
|
||||
}
|
||||
|
||||
handleBackgroundColorChange(color: string) {
|
||||
this.settingsForm.markAsDirty();
|
||||
this.settingsForm.markAsTouched();
|
||||
if (this.user?.preferences) {
|
||||
this.user.preferences.backgroundColor = color;
|
||||
}
|
||||
|
||||
this.settingsForm.get('backgroundColor')?.setValue(color);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
@ -106,7 +106,7 @@
|
||||
|
||||
"user-preferences": {
|
||||
"title": "User Dashboard",
|
||||
"pref-description": "These are global settings that are bound to your account.",
|
||||
"pref-description": "These are global settings that are bound to your account. Reader settings are located in Reading Profiles.",
|
||||
|
||||
"account-tab": "{{tabs.account-tab}}",
|
||||
"preferences-tab": "{{tabs.preferences-tab}}",
|
||||
@ -140,60 +140,6 @@
|
||||
"want-to-read-sync-label": "Want To Read Sync",
|
||||
"want-to-read-sync-tooltip": "Allow Kavita to add items to your Want to Read list based on AniList and MAL series in Pending readlist",
|
||||
|
||||
"image-reader-settings-title": "Image Reader",
|
||||
"reading-direction-label": "Reading Direction",
|
||||
"reading-direction-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.",
|
||||
"scaling-option-label": "Scaling Options",
|
||||
"scaling-option-tooltip": "How to scale the image to your screen.",
|
||||
"page-splitting-label": "Page Splitting",
|
||||
"page-splitting-tooltip": "How to split a full width image (ie both left and right images are combined)",
|
||||
"reading-mode-label": "Reading Mode",
|
||||
"reading-mode-tooltip": "Change reader to paginate vertically, horizontally, or have an infinite scroll",
|
||||
"layout-mode-label": "Layout Mode",
|
||||
"layout-mode-tooltip": "Render a single image to the screen or two side-by-side images",
|
||||
"background-color-label": "Background Color",
|
||||
"background-color-tooltip": "Background Color of Image Reader",
|
||||
"auto-close-menu-label": "Auto Close Menu",
|
||||
"auto-close-menu-tooltip": "Should menu auto close",
|
||||
"show-screen-hints-label": "Show Screen Hints",
|
||||
"show-screen-hints-tooltip": "Show an overlay to help understand pagination area and direction",
|
||||
"emulate-comic-book-label": "Emulate comic book",
|
||||
"emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book",
|
||||
"swipe-to-paginate-label": "Swipe to Paginate",
|
||||
"swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered",
|
||||
"allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode",
|
||||
"allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.",
|
||||
|
||||
"book-reader-settings-title": "Book Reader",
|
||||
"tap-to-paginate-label": "Tap to Paginate",
|
||||
"tap-to-paginate-tooltip": "Should the sides of the book reader screen allow tapping on it to move to prev/next page",
|
||||
"immersive-mode-label": "Immersive Mode",
|
||||
"immersive-mode-tooltip": "This will hide the menu behind a click on the reader document and turn tap to paginate on",
|
||||
"reading-direction-book-label": "Reading Direction",
|
||||
"reading-direction-book-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.",
|
||||
"font-family-label": "Font Family",
|
||||
"font-family-tooltip": "Font family to load up. Default will load the book's default font",
|
||||
"writing-style-label": "Writing Style",
|
||||
"writing-style-tooltip": "Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.",
|
||||
"layout-mode-book-label": "Layout Mode",
|
||||
"layout-mode-book-tooltip": "How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page",
|
||||
"color-theme-book-label": "Color Theme",
|
||||
"color-theme-book-tooltip": "What color theme to apply to the book reader content and menu",
|
||||
"font-size-book-label": "Font Size",
|
||||
"font-size-book-tooltip": "Percent of scaling to apply to font in the book",
|
||||
"line-height-book-label": "Line Spacing",
|
||||
"line-height-book-tooltip": "How much spacing between the lines of the book",
|
||||
"margin-book-label": "Margin",
|
||||
"margin-book-tooltip": "How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.",
|
||||
|
||||
"pdf-reader-settings-title": "PDF Reader",
|
||||
"pdf-scroll-mode-label": "Scroll Mode",
|
||||
"pdf-scroll-mode-tooltip": "How you scroll through pages. Vertical/Horizontal and Tap to Paginate (no scroll)",
|
||||
"pdf-spread-mode-label": "Spread Mode",
|
||||
"pdf-spread-mode-tooltip": "How pages should be laid out. Single or double (odd/even)",
|
||||
"pdf-theme-label": "Theme",
|
||||
"pdf-theme-tooltip": "Color theme of the reader",
|
||||
|
||||
"clients-opds-alert": "OPDS is not enabled on this server. This will not affect Tachiyomi users.",
|
||||
"clients-opds-description": "All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.",
|
||||
"clients-api-key-tooltip": "The API key is like a password. Resetting it will invalidate any existing clients.",
|
||||
@ -941,7 +887,7 @@
|
||||
"series-detail": {
|
||||
"page-settings-title": "Page Settings",
|
||||
"close": "{{common.close}}",
|
||||
"layout-mode-label": "{{user-preferences.layout-mode-book-label}}",
|
||||
"layout-mode-label": "{{manage-reading-profiles.layout-mode-book-label}}",
|
||||
"layout-mode-option-card": "Card",
|
||||
"layout-mode-option-list": "List",
|
||||
"continue-from": "Continue {{title}}",
|
||||
@ -1181,36 +1127,45 @@
|
||||
|
||||
"reader-settings": {
|
||||
"general-settings-title": "General Settings",
|
||||
"font-family-label": "{{user-preferences.font-family-label}}",
|
||||
"font-size-label": "{{user-preferences.font-size-book-label}}",
|
||||
"line-spacing-label": "{{user-preferences.line-height-book-label}}",
|
||||
"margin-label": "{{user-preferences.margin-book-label}}",
|
||||
"font-family-label": "{{manage-reading-profiles.font-family-label}}",
|
||||
"font-size-label": "{{manage-reading-profiles.font-size-book-label}}",
|
||||
"line-spacing-label": "{{manage-reading-profiles.line-height-book-label}}",
|
||||
"margin-label": "{{manage-reading-profiles.margin-book-label}}",
|
||||
"reset-to-defaults": "Reset to Defaults",
|
||||
"update-parent": "Save to {{name}}",
|
||||
"loading": "loading",
|
||||
"create-new": "New profile from implicit",
|
||||
"create-new-tooltip": "Create a new manageable profile from your current implicit one",
|
||||
"reading-profile-updated": "Reading profile updated",
|
||||
"reading-profile-promoted": "Reading profile promoted",
|
||||
"reader-settings-title": "Reader Settings",
|
||||
"reading-direction-label": "{{user-preferences.reading-direction-book-label}}",
|
||||
"reading-direction-label": "{{manage-reading-profiles.reading-direction-book-label}}",
|
||||
"right-to-left": "Right to Left",
|
||||
"left-to-right": "Left to Right",
|
||||
"horizontal": "Horizontal",
|
||||
"vertical": "Vertical",
|
||||
"writing-style-label": "{{user-preferences.writing-style-label}}",
|
||||
"writing-style-label": "{{manage-reading-profiles.writing-style-label}}",
|
||||
"writing-style-tooltip": "Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.",
|
||||
"tap-to-paginate-label": "Tap Pagination",
|
||||
"tap-to-paginate-tooltip": "Click the edges of the screen to paginate",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"immersive-mode-label": "{{user-preferences.immersive-mode-label}}",
|
||||
"immersive-mode-label": "{{manage-reading-profiles.immersive-mode-label}}",
|
||||
"immersive-mode-tooltip": "This will hide the menu behind a click on the reader document and turn tap to paginate on",
|
||||
"fullscreen-label": "Fullscreen",
|
||||
"fullscreen-tooltip": "Put reader in fullscreen mode",
|
||||
"exit": "Exit",
|
||||
"enter": "Enter",
|
||||
"layout-mode-label": "{{user-preferences.layout-mode-book-label}}",
|
||||
"layout-mode-label": "{{manage-reading-profiles.layout-mode-book-label}}",
|
||||
"layout-mode-tooltip": "Scroll: Mirrors epub file (usually one long scrolling page per chapter).<br/>1 Column: Creates a single virtual page at a time.<br/>2 Column: Creates two virtual pages at a time laid out side-by-side.",
|
||||
"layout-mode-option-scroll": "Scroll",
|
||||
"layout-mode-option-1col": "1 Column",
|
||||
"layout-mode-option-2col": "2 Column",
|
||||
"color-theme-title": "Color Theme",
|
||||
|
||||
"line-spacing-min-label": "1x",
|
||||
"line-spacing-max-label": "2.5x",
|
||||
|
||||
"theme-dark": "Dark",
|
||||
"theme-black": "Black",
|
||||
"theme-white": "White",
|
||||
@ -1324,6 +1279,17 @@
|
||||
"create": "{{common.create}}"
|
||||
},
|
||||
|
||||
"bulk-set-reading-profile-modal": {
|
||||
"title": "Set Reading profile",
|
||||
"close": "{{common.close}}",
|
||||
"filter-label": "{{common.filter}}",
|
||||
"clear": "{{common.clear}}",
|
||||
"no-data": "No collections created yet",
|
||||
"loading": "{{common.loading}}",
|
||||
"create": "{{common.create}}",
|
||||
"bound": "Bound"
|
||||
},
|
||||
|
||||
"entity-title": {
|
||||
"special": "Special",
|
||||
"issue-num": "{{common.issue-hash-num}}",
|
||||
@ -1720,6 +1686,7 @@
|
||||
"scrobble-holds": "Scrobble Holds",
|
||||
"account": "Account",
|
||||
"preferences": "Preferences",
|
||||
"reading-profiles": "Reading Profiles",
|
||||
"clients": "API Key / OPDS",
|
||||
"devices": "Devices",
|
||||
"user-stats": "Stats",
|
||||
@ -1990,7 +1957,10 @@
|
||||
|
||||
"manga-reader": {
|
||||
"back": "Back",
|
||||
"save-globally": "Save Globally",
|
||||
"update-parent": "{{reader-settings.update-parent}}",
|
||||
"loading": "{{reader-settings.loading}}",
|
||||
"create-new": "{{reader-settings.create-new}}",
|
||||
"create-new-tooltip": "{{reader-settings.create-new-tooltip}}",
|
||||
"incognito-alt": "Incognito mode is on. Toggle to turn off.",
|
||||
"incognito-title": "Incognito Mode:",
|
||||
"shortcuts-menu-alt": "Keyboard Shortcuts Modal",
|
||||
@ -2012,9 +1982,9 @@
|
||||
"height": "Height",
|
||||
"width": "Width",
|
||||
"width-override-label": "Width Override",
|
||||
"off": "Off",
|
||||
"off": "{{reader-settings.off}}",
|
||||
"original": "Original",
|
||||
"auto-close-menu-label": "{{user-preferences.auto-close-menu-label}}",
|
||||
"auto-close-menu-label": "{{manage-reading-profiles.auto-close-menu-label}}",
|
||||
"swipe-enabled-label": "Swipe Enabled",
|
||||
"enable-comic-book-label": "Emulate comic book",
|
||||
"brightness-label": "Brightness",
|
||||
@ -2026,8 +1996,9 @@
|
||||
"layout-mode-switched": "Layout mode switched to Single due to insufficient space to render double layout",
|
||||
"no-next-chapter": "No Next Chapter",
|
||||
"no-prev-chapter": "No Previous Chapter",
|
||||
"user-preferences-updated": "User preferences updated",
|
||||
"emulate-comic-book-label": "{{user-preferences.emulate-comic-book-label}}",
|
||||
"reading-profile-updated": "Reading profile updated",
|
||||
"reading-profile-promoted": "Reading profile promoted",
|
||||
"emulate-comic-book-label": "{{manage-reading-profiles.emulate-comic-book-label}}",
|
||||
"series-progress": "Series Progress: {{percentage}}"
|
||||
},
|
||||
|
||||
@ -2708,7 +2679,9 @@
|
||||
"bulk-delete-libraries": "Are you sure you want to delete {{count}} libraries?",
|
||||
"match-success": "Series matched correctly",
|
||||
"webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.",
|
||||
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services."
|
||||
"scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.",
|
||||
"series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}",
|
||||
"library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}"
|
||||
},
|
||||
|
||||
"read-time-pipe": {
|
||||
@ -2762,6 +2735,13 @@
|
||||
"remove-from-on-deck": "Remove From On Deck",
|
||||
"remove-from-on-deck-tooltip": "Remove series from showing from On Deck",
|
||||
|
||||
"reading-profiles": "Reading Profiles",
|
||||
"set-reading-profile": "Set Reading Profile",
|
||||
"set-reading-profile-tooltip": "Bind a Reading Profile to this Library",
|
||||
"clear-reading-profile": "Clear Reading Profile",
|
||||
"clear-reading-profile-tooltip": "Clear Reading Profile for this Library",
|
||||
"cleared-profile": "Cleared Reading Profile",
|
||||
|
||||
"others": "Others",
|
||||
"add-to-reading-list": "Add to Reading List",
|
||||
"add-to-reading-list-tooltip": "Add to a Reading List",
|
||||
@ -2843,6 +2823,80 @@
|
||||
"pdf-dark": "Dark"
|
||||
},
|
||||
|
||||
"manage-reading-profiles": {
|
||||
"description": "Not all your series may be read in the same way, set up distinct reading profiles per library or series to make getting back in your series as seamless as possible.",
|
||||
"extra-tip": "Assign reading profiles via the action menu on series and libraries, or in bulk. When changing settings in a reader, a hidden profile is created that remembers your choices for that series (not for pdfs). This profile is removed when you assign or update one of your own reading profiles to the series.",
|
||||
"profiles-title": "Your reading profiles",
|
||||
"default-profile": "Default",
|
||||
"add": "{{common.add}}",
|
||||
"add-tooltip": "Your new profile will be saved after making a change to it",
|
||||
"make-default": "Set as default",
|
||||
"no-selected": "No profile selected",
|
||||
"confirm": "Are you sure you want to delete the reading profile {{name}}?",
|
||||
"selection-tip": "Select a profile from the list, or create a new one at the top right",
|
||||
|
||||
"image-reader-settings-title": "Image Reader",
|
||||
"reading-direction-label": "Reading Direction",
|
||||
"reading-direction-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.",
|
||||
"scaling-option-label": "Scaling Options",
|
||||
"scaling-option-tooltip": "How to scale the image to your screen.",
|
||||
"page-splitting-label": "Page Splitting",
|
||||
"page-splitting-tooltip": "How to split a full width image (ie both left and right images are combined)",
|
||||
"reading-mode-label": "Reading Mode",
|
||||
"reading-mode-tooltip": "Change reader to paginate vertically, horizontally, or have an infinite scroll",
|
||||
"layout-mode-label": "Layout Mode",
|
||||
"layout-mode-tooltip": "Render a single image to the screen or two side-by-side images",
|
||||
"background-color-label": "Background Color",
|
||||
"background-color-tooltip": "Background Color of Image Reader",
|
||||
"auto-close-menu-label": "Auto Close Menu",
|
||||
"auto-close-menu-tooltip": "Should menu auto close",
|
||||
"show-screen-hints-label": "Show Screen Hints",
|
||||
"show-screen-hints-tooltip": "Show an overlay to help understand pagination area and direction",
|
||||
"emulate-comic-book-label": "Emulate comic book",
|
||||
"emulate-comic-book-tooltip": "Applies a shadow effect to emulate reading from a book",
|
||||
"swipe-to-paginate-label": "Swipe to Paginate",
|
||||
"swipe-to-paginate-tooltip": "Should swiping on the screen cause the next or previous page to be triggered",
|
||||
"allow-auto-webtoon-reader-label": "Automatic Webtoon Reader Mode",
|
||||
"allow-auto-webtoon-reader-tooltip": "Switch into Webtoon Reader mode if pages look like a webtoon. Some false positives may occur.",
|
||||
"width-override-label": "{{manga-reader.width-override-label}}",
|
||||
"width-override-tooltip": "Override width of images in the reader",
|
||||
"reset": "{{common.reset}}",
|
||||
|
||||
"book-reader-settings-title": "Book Reader",
|
||||
"tap-to-paginate-label": "Tap to Paginate",
|
||||
"tap-to-paginate-tooltip": "Should the sides of the book reader screen allow tapping on it to move to prev/next page",
|
||||
"immersive-mode-label": "Immersive Mode",
|
||||
"immersive-mode-tooltip": "This will hide the menu behind a click on the reader document and turn tap to paginate on",
|
||||
"reading-direction-book-label": "Reading Direction",
|
||||
"reading-direction-book-tooltip": "Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page.",
|
||||
"font-family-label": "Font Family",
|
||||
"font-family-tooltip": "Font family to load up. Default will load the book's default font",
|
||||
"writing-style-label": "Writing Style",
|
||||
"writing-style-tooltip": "Changes the direction of the text. Horizontal is left to right, vertical is top to bottom.",
|
||||
"layout-mode-book-label": "Layout Mode",
|
||||
"layout-mode-book-tooltip": "How content should be laid out. Scroll is as the book packs it. 1 or 2 Column fits to the height of the device and fits 1 or 2 columns of text per page",
|
||||
"color-theme-book-label": "Color Theme",
|
||||
"color-theme-book-tooltip": "What color theme to apply to the book reader content and menu",
|
||||
"font-size-book-label": "Font Size",
|
||||
"font-size-book-tooltip": "Percent of scaling to apply to font in the book",
|
||||
"line-height-book-label": "Line Spacing",
|
||||
"line-height-book-tooltip": "How much spacing between the lines of the book",
|
||||
"margin-book-label": "Margin",
|
||||
"margin-book-tooltip": "How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting.",
|
||||
|
||||
"pdf-reader-settings-title": "PDF Reader",
|
||||
"pdf-scroll-mode-label": "Scroll Mode",
|
||||
"pdf-scroll-mode-tooltip": "How you scroll through pages. Vertical/Horizontal and Tap to Paginate (no scroll)",
|
||||
"pdf-spread-mode-label": "Spread Mode",
|
||||
"pdf-spread-mode-tooltip": "How pages should be laid out. Single or double (odd/even)",
|
||||
"pdf-theme-label": "Theme",
|
||||
"pdf-theme-tooltip": "Color theme of the reader",
|
||||
|
||||
"reading-profile-series-settings-title": "Series",
|
||||
"reading-profile-library-settings-title": "Library",
|
||||
"delete": "{{common.delete}}"
|
||||
},
|
||||
|
||||
|
||||
"validation": {
|
||||
"required-field": "This field is required",
|
||||
|
Loading…
x
Reference in New Issue
Block a user