mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-06-01 04:34:49 -04:00
Polish for Release (#2314)
This commit is contained in:
parent
fe4af4b648
commit
59b950c4bd
@ -10,8 +10,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.8" />
|
<PackageReference Include="BenchmarkDotNet" Version="0.13.9" />
|
||||||
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.8" />
|
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.9" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
@ -6,13 +6,13 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.12" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
|
||||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.69" />
|
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="19.2.69" />
|
||||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.69" />
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="19.2.69" />
|
||||||
<PackageReference Include="xunit" Version="2.5.1" />
|
<PackageReference Include="xunit" Version="2.5.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.1">
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
@ -68,22 +68,22 @@
|
|||||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
|
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.53" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.53" />
|
||||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.12" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.11">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
|
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.3.2" />
|
||||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||||
<PackageReference Include="NetVips" Version="2.3.1" />
|
<PackageReference Include="NetVips" Version="2.3.1" />
|
||||||
<PackageReference Include="NetVips.Native" Version="8.14.5" />
|
<PackageReference Include="NetVips.Native" Version="8.14.5" />
|
||||||
<PackageReference Include="NReco.Logging.File" Version="1.1.6" />
|
<PackageReference Include="NReco.Logging.File" Version="1.1.7" />
|
||||||
<PackageReference Include="Serilog" Version="3.0.1" />
|
<PackageReference Include="Serilog" Version="3.0.1" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.2.0-dev-00752" />
|
||||||
@ -93,15 +93,15 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
<PackageReference Include="Serilog.Sinks.SignalR.Core" Version="0.1.2" />
|
||||||
<PackageReference Include="SharpCompress" Version="0.34.0" />
|
<PackageReference Include="SharpCompress" Version="0.34.1" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.2" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.2" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.10.0.77988">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.12.0.78982">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.11" />
|
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.11" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.0.2" />
|
||||||
<PackageReference Include="System.IO.Abstractions" Version="19.2.69" />
|
<PackageReference Include="System.IO.Abstractions" Version="19.2.69" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
|
||||||
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
||||||
|
@ -306,12 +306,13 @@ public class AccountController : BaseApiController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets the API Key assigned with a user
|
/// Resets the API Key assigned with a user
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This will log unauthorized requests to Security log</remarks>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("reset-api-key")]
|
[HttpPost("reset-api-key")]
|
||||||
public async Task<ActionResult<string>> ResetApiKey()
|
public async Task<ActionResult<string>> ResetApiKey()
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||||
if (user == null) return Unauthorized();
|
if (user == null) throw new KavitaUnauthenticatedUserException();
|
||||||
|
|
||||||
user.ApiKey = HashUtil.ApiKey();
|
user.ApiKey = HashUtil.ApiKey();
|
||||||
|
|
||||||
|
@ -98,8 +98,11 @@ public class LibraryController : BaseApiController
|
|||||||
admin.Libraries.Add(library);
|
admin.Libraries.Add(library);
|
||||||
}
|
}
|
||||||
|
|
||||||
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
|
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
|
||||||
|
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
|
||||||
|
|
||||||
|
// Assign all the necessary users with this library side nav
|
||||||
|
var userIds = admins.Select(u => u.Id).Append(User.GetUserId()).ToList();
|
||||||
var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams))
|
var userNeedingNewLibrary = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams))
|
||||||
.Where(u => userIds.Contains(u.Id))
|
.Where(u => userIds.Contains(u.Id))
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -119,10 +122,8 @@ public class LibraryController : BaseApiController
|
|||||||
_unitOfWork.UserRepository.Update(user);
|
_unitOfWork.UserRepository.Update(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
|
if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library"));
|
||||||
|
|
||||||
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
|
|
||||||
await _libraryWatcher.RestartWatching();
|
await _libraryWatcher.RestartWatching();
|
||||||
_taskScheduler.ScanLibrary(library.Id);
|
_taskScheduler.ScanLibrary(library.Id);
|
||||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||||
|
@ -216,6 +216,9 @@ public class OpdsController : BaseApiController
|
|||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
|
||||||
|
{
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
{
|
{
|
||||||
Id = "allSmartFilters",
|
Id = "allSmartFilters",
|
||||||
@ -229,6 +232,25 @@ public class OpdsController : BaseApiController
|
|||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// if ((await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId)).Any())
|
||||||
|
// {
|
||||||
|
// feed.Entries.Add(new FeedEntry()
|
||||||
|
// {
|
||||||
|
// Id = "allExternalSources",
|
||||||
|
// Title = await _localizationService.Translate(userId, "external-sources"),
|
||||||
|
// Content = new FeedEntryContent()
|
||||||
|
// {
|
||||||
|
// Text = await _localizationService.Translate(userId, "browse-external-sources")
|
||||||
|
// },
|
||||||
|
// Links = new List<FeedLink>()
|
||||||
|
// {
|
||||||
|
// CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/external-sources"),
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
return CreateXmlResult(SerializeXml(feed));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,6 +328,38 @@ public class OpdsController : BaseApiController
|
|||||||
return CreateXmlResult(SerializeXml(feed));
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{apiKey}/external-sources")]
|
||||||
|
[Produces("application/xml")]
|
||||||
|
public async Task<IActionResult> GetExternalSources(string apiKey)
|
||||||
|
{
|
||||||
|
// NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work
|
||||||
|
var userId = await GetUser(apiKey);
|
||||||
|
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
|
||||||
|
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
|
||||||
|
var (baseUrl, prefix) = await GetPrefix();
|
||||||
|
|
||||||
|
var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
|
||||||
|
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{prefix}{apiKey}/external-sources", apiKey, prefix);
|
||||||
|
SetFeedId(feed, "externalSources");
|
||||||
|
foreach (var externalSource in externalSources)
|
||||||
|
{
|
||||||
|
var opdsUrl = $"{externalSource.Host}api/opds/{externalSource.ApiKey}";
|
||||||
|
feed.Entries.Add(new FeedEntry()
|
||||||
|
{
|
||||||
|
Id = externalSource.Id.ToString(),
|
||||||
|
Title = externalSource.Name,
|
||||||
|
Summary = externalSource.Host,
|
||||||
|
Links = new List<FeedLink>()
|
||||||
|
{
|
||||||
|
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, opdsUrl),
|
||||||
|
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{opdsUrl}/favicon")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateXmlResult(SerializeXml(feed));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("{apiKey}/libraries")]
|
[HttpGet("{apiKey}/libraries")]
|
||||||
[Produces("application/xml")]
|
[Produces("application/xml")]
|
||||||
@ -318,12 +372,16 @@ public class OpdsController : BaseApiController
|
|||||||
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
||||||
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
|
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
|
||||||
SetFeedId(feed, "libraries");
|
SetFeedId(feed, "libraries");
|
||||||
foreach (var library in libraries)
|
|
||||||
|
// Ensure libraries follow SideNav order
|
||||||
|
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId, false);
|
||||||
|
foreach (var sideNavStream in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library))
|
||||||
{
|
{
|
||||||
|
var library = sideNavStream.Library;
|
||||||
feed.Entries.Add(new FeedEntry()
|
feed.Entries.Add(new FeedEntry()
|
||||||
{
|
{
|
||||||
Id = library.Id.ToString(),
|
Id = library!.Id.ToString(),
|
||||||
Title = library.Name,
|
Title = library.Name!,
|
||||||
Links = new List<FeedLink>()
|
Links = new List<FeedLink>()
|
||||||
{
|
{
|
||||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),
|
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries/{library.Id}"),
|
||||||
|
@ -28,6 +28,7 @@ public class PluginController : BaseApiController
|
|||||||
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
|
/// Authenticate with the Server given an apiKey. This will log you in by returning the user object and the JWT token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>This API is not fully built out and may require more information in later releases</remarks>
|
/// <remarks>This API is not fully built out and may require more information in later releases</remarks>
|
||||||
|
/// <remarks>This will log unauthorized requests to Security log</remarks>
|
||||||
/// <param name="apiKey">API key which will be used to authenticate and return a valid user token back</param>
|
/// <param name="apiKey">API key which will be used to authenticate and return a valid user token back</param>
|
||||||
/// <param name="pluginName">Name of the Plugin</param>
|
/// <param name="pluginName">Name of the Plugin</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
@ -37,8 +38,19 @@ public class PluginController : BaseApiController
|
|||||||
{
|
{
|
||||||
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
|
// NOTE: In order to log information about plugins, we need some Plugin Description information for each request
|
||||||
// Should log into access table so we can tell the user
|
// Should log into access table so we can tell the user
|
||||||
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var userAgent = HttpContext.Request.Headers["User-Agent"];
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
if (userId <= 0) return Unauthorized();
|
if (userId <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("A Plugin ({PluginName}) tried to authenticate with an apiKey that doesn't match. Information {Information}", pluginName, new
|
||||||
|
{
|
||||||
|
IpAddress = ipAddress,
|
||||||
|
UserAgent = userAgent,
|
||||||
|
ApiKey = apiKey
|
||||||
|
});
|
||||||
|
throw new KavitaUnauthenticatedUserException();
|
||||||
|
}
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
|
||||||
_logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user!.UserName, userId);
|
_logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user!.UserName, userId);
|
||||||
return new UserDto
|
return new UserDto
|
||||||
@ -54,6 +66,7 @@ public class PluginController : BaseApiController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the version of the Kavita install
|
/// Returns the version of the Kavita install
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This will log unauthorized requests to Security log</remarks>
|
||||||
/// <param name="apiKey">Required for authenticating to get result</param>
|
/// <param name="apiKey">Required for authenticating to get result</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@ -61,7 +74,7 @@ public class PluginController : BaseApiController
|
|||||||
public async Task<ActionResult<string>> GetVersion([Required] string apiKey)
|
public async Task<ActionResult<string>> GetVersion([Required] string apiKey)
|
||||||
{
|
{
|
||||||
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
|
||||||
if (userId <= 0) return Unauthorized();
|
if (userId <= 0) throw new KavitaUnauthenticatedUserException();
|
||||||
return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
|
return Ok((await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,6 +111,7 @@ public class ScrobblingController : BaseApiController
|
|||||||
pagination ??= UserParams.Default;
|
pagination ??= UserParams.Default;
|
||||||
var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), filter, pagination);
|
var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), filter, pagination);
|
||||||
Response.AddPaginationHeader(events.CurrentPage, events.PageSize, events.TotalCount, events.TotalPages);
|
Response.AddPaginationHeader(events.CurrentPage, events.PageSize, events.TotalCount, events.TotalPages);
|
||||||
|
|
||||||
return Ok(events);
|
return Ok(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -183,4 +183,11 @@ public class StreamController : BaseApiController
|
|||||||
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
|
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("bulk-sidenav-stream-visibility")]
|
||||||
|
public async Task<ActionResult> BulkUpdateSideNavStream(BulkUpdateSideNavStreamVisibilityDto dto)
|
||||||
|
{
|
||||||
|
await _streamService.UpdateSideNavStreamBulk(User.GetUserId(), dto);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
9
API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs
Normal file
9
API/DTOs/SideNav/BulkUpdateSideNavStreamVisibilityDto.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace API.DTOs.SideNav;
|
||||||
|
|
||||||
|
public class BulkUpdateSideNavStreamVisibilityDto
|
||||||
|
{
|
||||||
|
public required IList<int> Ids { get; set; }
|
||||||
|
public required bool Visibility { get; set; }
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace API.Data.ManualMigrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// v0.7.8.6 explicitly introduced DashboardStream and v0.7.8.9 changed the default seed titles to use locale strings.
|
||||||
|
/// This migration will target nightly releases and should be removed before v0.7.9 release.
|
||||||
|
/// </summary>
|
||||||
|
public static class MigrateDashboardStreamNamesToLocaleKeys
|
||||||
|
{
|
||||||
|
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
|
||||||
|
{
|
||||||
|
var allStreams = await unitOfWork.UserRepository.GetAllDashboardStreams();
|
||||||
|
if (!allStreams.Any(s => s.Name.Equals("On Deck"))) return;
|
||||||
|
|
||||||
|
logger.LogCritical("Running MigrateDashboardStreamNamesToLocaleKeys migration. Please be patient, this may take some time depending on the size of your library. Do not abort, this can break your Database");
|
||||||
|
foreach (var stream in allStreams.Where(s => s.IsProvided))
|
||||||
|
{
|
||||||
|
stream.Name = stream.Name switch
|
||||||
|
{
|
||||||
|
"On Deck" => "on-deck",
|
||||||
|
"Recently Updated" => "recently-updated",
|
||||||
|
"Newly Added" => "newly-added",
|
||||||
|
"More In" => "more-in-genre",
|
||||||
|
_ => stream.Name
|
||||||
|
};
|
||||||
|
unitOfWork.UserRepository.Update(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
await unitOfWork.CommitAsync();
|
||||||
|
logger.LogInformation("MigrateDashboardStreamNamesToLocaleKeys migration finished");
|
||||||
|
}
|
||||||
|
}
|
@ -84,6 +84,7 @@ public interface IUserRepository
|
|||||||
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
|
Task<IList<ScrobbleHoldDto>> GetHolds(int userId);
|
||||||
Task<string> GetLocale(int userId);
|
Task<string> GetLocale(int userId);
|
||||||
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
|
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
|
||||||
|
Task<IList<AppUserDashboardStream>> GetAllDashboardStreams();
|
||||||
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
|
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
|
||||||
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
|
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
|
||||||
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
|
Task<IList<SideNavStreamDto>> GetSideNavStreams(int userId, bool visibleOnly = false);
|
||||||
@ -91,6 +92,7 @@ public interface IUserRepository
|
|||||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
|
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithFilter(int filterId);
|
||||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
|
Task<IList<AppUserSideNavStream>> GetSideNavStreamsByLibraryId(int libraryId);
|
||||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
|
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
|
||||||
|
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserRepository : IUserRepository
|
public class UserRepository : IUserRepository
|
||||||
@ -356,6 +358,13 @@ public class UserRepository : IUserRepository
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<AppUserDashboardStream>> GetAllDashboardStreams()
|
||||||
|
{
|
||||||
|
return await _context.AppUserDashboardStream
|
||||||
|
.OrderBy(d => d.Order)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
|
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
|
||||||
{
|
{
|
||||||
return await _context.AppUserDashboardStream
|
return await _context.AppUserDashboardStream
|
||||||
@ -453,6 +462,13 @@ public class UserRepository : IUserRepository
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds)
|
||||||
|
{
|
||||||
|
return await _context.AppUserSideNavStream
|
||||||
|
.Where(d => streamIds.Contains(d.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||||
{
|
{
|
||||||
|
@ -151,6 +151,8 @@
|
|||||||
"collections": "All Collections",
|
"collections": "All Collections",
|
||||||
"browse-collections": "Browse by Collections",
|
"browse-collections": "Browse by Collections",
|
||||||
"smart-filters": "Smart Filters",
|
"smart-filters": "Smart Filters",
|
||||||
|
"external-sources": "External Sources",
|
||||||
|
"browse-external-sources": "Browse External Sources",
|
||||||
"browse-smart-filters": "Browse by Smart Filters",
|
"browse-smart-filters": "Browse by Smart Filters",
|
||||||
"reading-list-restricted": "Reading list does not exist or you don't have access",
|
"reading-list-restricted": "Reading list does not exist or you don't have access",
|
||||||
"query-required": "You must pass a query parameter",
|
"query-required": "You must pass a query parameter",
|
||||||
|
69
API/Middleware/SecurityMiddleware.cs
Normal file
69
API/Middleware/SecurityMiddleware.cs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using API.Errors;
|
||||||
|
using Kavita.Common;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Serilog;
|
||||||
|
using ILogger = Serilog.ILogger;
|
||||||
|
|
||||||
|
namespace API.Middleware;
|
||||||
|
|
||||||
|
public class SecurityEventMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
public SecurityEventMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
|
||||||
|
_logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Debug()
|
||||||
|
.WriteTo.File(Path.Join(Directory.GetCurrentDirectory(), "config/logs/", "security.log"), rollingInterval: RollingInterval.Day)
|
||||||
|
.CreateLogger();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (KavitaUnauthenticatedUserException ex)
|
||||||
|
{
|
||||||
|
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var requestMethod = context.Request.Method;
|
||||||
|
var requestPath = context.Request.Path;
|
||||||
|
var userAgent = context.Request.Headers["User-Agent"];
|
||||||
|
var securityEvent = new
|
||||||
|
{
|
||||||
|
IpAddress = ipAddress,
|
||||||
|
RequestMethod = requestMethod,
|
||||||
|
RequestPath = requestPath,
|
||||||
|
UserAgent = userAgent,
|
||||||
|
CreatedAt = DateTime.Now,
|
||||||
|
CreatedAtUtc = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
_logger.Information("Unauthorized User attempting to access API. {@Event}", securityEvent);
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
context.Response.StatusCode = (int) HttpStatusCode.Unauthorized;
|
||||||
|
|
||||||
|
const string errorMessage = "Unauthorized";
|
||||||
|
|
||||||
|
var response = new ApiException(context.Response.StatusCode, errorMessage, ex.StackTrace);
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy =
|
||||||
|
JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(response, options);
|
||||||
|
|
||||||
|
await context.Response.WriteAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -24,6 +24,7 @@ public interface IStreamService
|
|||||||
Task<DashboardStreamDto> CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId);
|
Task<DashboardStreamDto> CreateDashboardStreamFromSmartFilter(int userId, int smartFilterId);
|
||||||
Task UpdateDashboardStream(int userId, DashboardStreamDto dto);
|
Task UpdateDashboardStream(int userId, DashboardStreamDto dto);
|
||||||
Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto);
|
Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto);
|
||||||
|
Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto);
|
||||||
Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId);
|
Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId);
|
||||||
Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId);
|
Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId);
|
||||||
Task UpdateSideNavStream(int userId, SideNavStreamDto dto);
|
Task UpdateSideNavStream(int userId, SideNavStreamDto dto);
|
||||||
@ -31,6 +32,7 @@ public interface IStreamService
|
|||||||
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
|
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
|
||||||
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
|
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
|
||||||
Task DeleteExternalSource(int userId, int externalSourceId);
|
Task DeleteExternalSource(int userId, int externalSourceId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StreamService : IStreamService
|
public class StreamService : IStreamService
|
||||||
@ -134,6 +136,20 @@ public class StreamService : IStreamService
|
|||||||
user.Id);
|
user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto)
|
||||||
|
{
|
||||||
|
var streams = await _unitOfWork.UserRepository.GetDashboardStreamsByIds(dto.Ids);
|
||||||
|
foreach (var stream in streams)
|
||||||
|
{
|
||||||
|
stream.Visible = dto.Visibility;
|
||||||
|
_unitOfWork.UserRepository.Update(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.CommitAsync();
|
||||||
|
await _eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),
|
||||||
|
userId);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId)
|
public async Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId)
|
||||||
{
|
{
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);
|
||||||
|
@ -252,6 +252,7 @@ public class Startup
|
|||||||
|
|
||||||
// v0.7.9
|
// v0.7.9
|
||||||
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
||||||
|
await MigrateDashboardStreamNamesToLocaleKeys.Migrate(unitOfWork, dataContext, logger);
|
||||||
|
|
||||||
// Update the version in the DB after all migrations are run
|
// Update the version in the DB after all migrations are run
|
||||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||||
@ -269,9 +270,9 @@ public class Startup
|
|||||||
logger.LogCritical(ex, "An error occurred during migration");
|
logger.LogCritical(ex, "An error occurred during migration");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.UseMiddleware<ExceptionMiddleware>();
|
app.UseMiddleware<ExceptionMiddleware>();
|
||||||
|
app.UseMiddleware<SecurityEventMiddleware>();
|
||||||
|
|
||||||
|
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
{
|
{
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
|
||||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.10.0.77988">
|
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.12.0.78982">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
25
Kavita.Common/KavitaUnauthenticatedUserException.cs
Normal file
25
Kavita.Common/KavitaUnauthenticatedUserException.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
|
namespace Kavita.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The user does not exist (aka unauthorized). This will be caught by middleware and Unauthorized() returned to UI
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>This will always log to Security Log</remarks>
|
||||||
|
[Serializable]
|
||||||
|
public class KavitaUnauthenticatedUserException : Exception
|
||||||
|
{
|
||||||
|
public KavitaUnauthenticatedUserException()
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public KavitaUnauthenticatedUserException(string message) : base(message)
|
||||||
|
{ }
|
||||||
|
|
||||||
|
public KavitaUnauthenticatedUserException(string message, Exception inner)
|
||||||
|
: base(message, inner) { }
|
||||||
|
|
||||||
|
protected KavitaUnauthenticatedUserException(SerializationInfo info, StreamingContext context)
|
||||||
|
: base(info, context)
|
||||||
|
{ }
|
||||||
|
}
|
1220
UI/Web/package-lock.json
generated
1220
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,22 +13,22 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^16.2.7",
|
"@angular/animations": "^16.2.9",
|
||||||
"@angular/cdk": "^16.2.6",
|
"@angular/cdk": "^16.2.8",
|
||||||
"@angular/common": "^16.2.7",
|
"@angular/common": "^16.2.9",
|
||||||
"@angular/compiler": "^16.2.7",
|
"@angular/compiler": "^16.2.9",
|
||||||
"@angular/core": "^16.2.7",
|
"@angular/core": "^16.2.9",
|
||||||
"@angular/forms": "^16.2.7",
|
"@angular/forms": "^16.2.9",
|
||||||
"@angular/localize": "^16.2.7",
|
"@angular/localize": "^16.2.9",
|
||||||
"@angular/platform-browser": "^16.2.7",
|
"@angular/platform-browser": "^16.2.9",
|
||||||
"@angular/platform-browser-dynamic": "^16.2.7",
|
"@angular/platform-browser-dynamic": "^16.2.9",
|
||||||
"@angular/router": "^16.2.7",
|
"@angular/router": "^16.2.9",
|
||||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||||
"@iplab/ngx-file-upload": "^16.0.2",
|
"@iplab/ngx-file-upload": "^16.0.2",
|
||||||
"@microsoft/signalr": "^7.0.11",
|
"@microsoft/signalr": "^7.0.12",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
"@ng-bootstrap/ng-bootstrap": "^15.1.1",
|
||||||
"@ngneat/transloco": "^5.0.7",
|
"@ngneat/transloco": "^6.0.0",
|
||||||
"@ngneat/transloco-locale": "^5.1.1",
|
"@ngneat/transloco-locale": "^5.1.1",
|
||||||
"@ngneat/transloco-persist-lang": "^5.0.0",
|
"@ngneat/transloco-persist-lang": "^5.0.0",
|
||||||
"@ngneat/transloco-persist-translations": "^5.0.0",
|
"@ngneat/transloco-persist-translations": "^5.0.0",
|
||||||
@ -41,10 +41,11 @@
|
|||||||
"eventsource": "^2.0.2",
|
"eventsource": "^2.0.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lazysizes": "^5.3.2",
|
"lazysizes": "^5.3.2",
|
||||||
|
"luxon": "^3.4.3",
|
||||||
"ng-circle-progress": "^1.7.1",
|
"ng-circle-progress": "^1.7.1",
|
||||||
"ng-select2-component": "^13.0.9",
|
"ng-select2-component": "^13.0.9",
|
||||||
"ngx-color-picker": "^15.0.0",
|
"ngx-color-picker": "^15.0.0",
|
||||||
"ngx-extended-pdf-viewer": "^16.2.16",
|
"ngx-extended-pdf-viewer": "^18.0.2",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-slider-v2": "^16.0.2",
|
"ngx-slider-v2": "^16.0.2",
|
||||||
"ngx-stars": "^1.6.5",
|
"ngx-stars": "^1.6.5",
|
||||||
@ -52,27 +53,28 @@
|
|||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"screenfull": "^6.0.2",
|
"screenfull": "^6.0.2",
|
||||||
"swiper": "^8.4.6",
|
"swiper": "^8.4.6",
|
||||||
"tslib": "^2.6.1",
|
"tslib": "^2.6.2",
|
||||||
"zone.js": "^0.13.0"
|
"zone.js": "^0.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^16.2.4",
|
"@angular-devkit/build-angular": "^16.2.6",
|
||||||
"@angular-eslint/builder": "^16.1.0",
|
"@angular-eslint/builder": "^16.2.0",
|
||||||
"@angular-eslint/eslint-plugin": "^16.1.0",
|
"@angular-eslint/eslint-plugin": "^16.2.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "^16.1.0",
|
"@angular-eslint/eslint-plugin-template": "^16.2.0",
|
||||||
"@angular-eslint/schematics": "^16.1.0",
|
"@angular-eslint/schematics": "^16.2.0",
|
||||||
"@angular-eslint/template-parser": "^16.1.0",
|
"@angular-eslint/template-parser": "^16.2.0",
|
||||||
"@angular/cli": "^16.2.4",
|
"@angular/cli": "^16.2.6",
|
||||||
"@angular/compiler-cli": "^16.2.7",
|
"@angular/compiler-cli": "^16.2.9",
|
||||||
"@types/d3": "^7.4.0",
|
"@types/d3": "^7.4.1",
|
||||||
"@types/node": "^20.4.8",
|
"@types/luxon": "^3.3.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
"@types/node": "^20.8.6",
|
||||||
"@typescript-eslint/parser": "^6.3.0",
|
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||||
"eslint": "^8.46.0",
|
"@typescript-eslint/parser": "^6.7.5",
|
||||||
|
"eslint": "^8.51.0",
|
||||||
"jsonminify": "^0.4.2",
|
"jsonminify": "^0.4.2",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.1.6",
|
"typescript": "^5.1.6",
|
||||||
"webpack-bundle-analyzer": "^4.8.0"
|
"webpack-bundle-analyzer": "^4.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,15 @@ export enum FilterField
|
|||||||
ReadingDate = 27
|
ReadingDate = 27
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allFields = Object.keys(FilterField)
|
|
||||||
|
const enumArray = Object.keys(FilterField)
|
||||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||||
.map(key => parseInt(key, 10))
|
.map(key => {
|
||||||
.sort((a, b) => a - b) as FilterField[];
|
// @ts-ignore
|
||||||
|
return ({key: key, value: FilterField[key]});
|
||||||
|
});
|
||||||
|
|
||||||
|
enumArray.sort((a, b) => a.value.localeCompare(b.value));
|
||||||
|
|
||||||
|
export const allFields = enumArray
|
||||||
|
.map(key => parseInt(key.key, 10))as FilterField[];
|
||||||
|
@ -114,7 +114,7 @@ export class AccountService {
|
|||||||
.pipe(map(res => res === "true"));
|
.pipe(map(res => res === "true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
login(model: {username: string, password: string}) {
|
login(model: {username: string, password: string, apiKey?: string}) {
|
||||||
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
|
return this.httpClient.post<User>(this.baseUrl + 'account/login', model).pipe(
|
||||||
map((response: User) => {
|
map((response: User) => {
|
||||||
const user = response;
|
const user = response;
|
||||||
|
@ -9,6 +9,7 @@ import { Series } from '../_models/series';
|
|||||||
import { Volume } from '../_models/volume';
|
import { Volume } from '../_models/volume';
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
import { DeviceService } from './device.service';
|
import { DeviceService } from './device.service';
|
||||||
|
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||||
|
|
||||||
export enum Action {
|
export enum Action {
|
||||||
Submenu = -1,
|
Submenu = -1,
|
||||||
@ -93,7 +94,9 @@ export enum Action {
|
|||||||
*/
|
*/
|
||||||
RemoveFromOnDeck = 19,
|
RemoveFromOnDeck = 19,
|
||||||
AddRuleGroup = 20,
|
AddRuleGroup = 20,
|
||||||
RemoveRuleGroup = 21
|
RemoveRuleGroup = 21,
|
||||||
|
MarkAsVisible = 22,
|
||||||
|
MarkAsInvisible = 23,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionItem<T> {
|
export interface ActionItem<T> {
|
||||||
@ -135,6 +138,8 @@ export class ActionFactoryService {
|
|||||||
|
|
||||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
bookmarkActions: Array<ActionItem<Series>> = [];
|
||||||
|
|
||||||
|
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||||
|
|
||||||
isAdmin = false;
|
isAdmin = false;
|
||||||
hasDownloadRole = false;
|
hasDownloadRole = false;
|
||||||
|
|
||||||
@ -160,6 +165,10 @@ export class ActionFactoryService {
|
|||||||
return this.applyCallbackToList(this.seriesActions, callback);
|
return this.applyCallbackToList(this.seriesActions, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSideNavStreamActions(callback: (action: ActionItem<SideNavStream>, series: SideNavStream) => void) {
|
||||||
|
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
||||||
|
}
|
||||||
|
|
||||||
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) {
|
getVolumeActions(callback: (action: ActionItem<Volume>, volume: Volume) => void) {
|
||||||
return this.applyCallbackToList(this.volumeActions, callback);
|
return this.applyCallbackToList(this.volumeActions, callback);
|
||||||
}
|
}
|
||||||
@ -564,6 +573,23 @@ export class ActionFactoryService {
|
|||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
this.sideNavStreamActions = [
|
||||||
|
{
|
||||||
|
action: Action.MarkAsVisible,
|
||||||
|
title: 'mark-visible',
|
||||||
|
callback: this.dummyCallback,
|
||||||
|
requiresAdmin: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: Action.MarkAsInvisible,
|
||||||
|
title: 'mark-invisible',
|
||||||
|
callback: this.dummyCallback,
|
||||||
|
requiresAdmin: false,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
|
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) {
|
||||||
|
@ -62,6 +62,10 @@ export class NavService {
|
|||||||
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream-from-external-source?externalSourceId=' + externalSourceId, {});
|
return this.httpClient.post<SideNavStream>(this.baseUrl + 'stream/add-sidenav-stream-from-external-source?externalSourceId=' + externalSourceId, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bulkToggleSideNavStreamVisibility(streamIds: Array<number>, targetVisibility: boolean) {
|
||||||
|
return this.httpClient.post(this.baseUrl + 'stream/bulk-sidenav-stream-visibility', {ids: streamIds, visibility: targetVisibility});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the top nav bar. This should be visible on all pages except the reader.
|
* Shows the top nav bar. This should be visible on all pages except the reader.
|
||||||
*/
|
*/
|
||||||
|
@ -44,14 +44,14 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngIf="events.length === 0">
|
<tr *ngIf="events.length === 0">
|
||||||
<td colspan="6">{{t('no-data')}}/td>
|
<td colspan="6">{{t('no-data')}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr *ngFor="let item of events; let idx = index;">
|
<tr *ngFor="let item of events; let idx = index;">
|
||||||
<td>
|
<td>
|
||||||
{{item.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium', } | defaultValue }}
|
{{item.createdUtc | utcToLocalTime | defaultValue}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{item.lastModifiedUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}
|
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{item.scrobbleEventType | scrobbleEventType}}
|
{{item.scrobbleEventType | scrobbleEventType}}
|
||||||
|
@ -14,11 +14,12 @@ import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
|||||||
import {TranslocoModule} from "@ngneat/transloco";
|
import {TranslocoModule} from "@ngneat/transloco";
|
||||||
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
|
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-scrobble-history',
|
selector: 'app-user-scrobble-history',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule],
|
imports: [CommonModule, ScrobbleEventTypePipe, NgbPagination, ReactiveFormsModule, SortableHeader, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe],
|
||||||
templateUrl: './user-scrobble-history.component.html',
|
templateUrl: './user-scrobble-history.component.html',
|
||||||
styleUrls: ['./user-scrobble-history.component.scss'],
|
styleUrls: ['./user-scrobble-history.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="typeahead-focus" class="form-label">{{t('path')}}</label>
|
<label for="typeahead-focus" class="form-label">{{t('path-label')}}</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
|
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
|
||||||
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
|
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
|
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{item.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}
|
{{item.createdUtc | utcToLocalTime | defaultValue }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{item.comment}}
|
{{item.comment}}
|
||||||
|
@ -29,11 +29,12 @@ import {TranslocoModule} from "@ngneat/transloco";
|
|||||||
import {DefaultDatePipe} from "../../pipe/default-date.pipe";
|
import {DefaultDatePipe} from "../../pipe/default-date.pipe";
|
||||||
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
|
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-scrobble-errors',
|
selector: 'app-manage-scrobble-errors',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule],
|
imports: [CommonModule, ReactiveFormsModule, FilterPipe, LoadingComponent, SortableHeader, TranslocoModule, DefaultDatePipe, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe],
|
||||||
templateUrl: './manage-scrobble-errors.component.html',
|
templateUrl: './manage-scrobble-errors.component.html',
|
||||||
styleUrls: ['./manage-scrobble-errors.component.scss'],
|
styleUrls: ['./manage-scrobble-errors.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
@ -58,7 +58,7 @@
|
|||||||
<td>
|
<td>
|
||||||
{{task.title | titlecase}}
|
{{task.title | titlecase}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{task.lastExecutionUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}</td>
|
<td>{{task.lastExecutionUtc | utcToLocalTime | defaultValue }}</td>
|
||||||
<td>{{task.cron}}</td>
|
<td>{{task.cron}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -14,6 +14,7 @@ import {DefaultValuePipe} from '../../pipe/default-value.pipe';
|
|||||||
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
|
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||||
|
|
||||||
interface AdhocTask {
|
interface AdhocTask {
|
||||||
name: string;
|
name: string;
|
||||||
@ -29,7 +30,7 @@ interface AdhocTask {
|
|||||||
styleUrls: ['./manage-tasks-settings.component.scss'],
|
styleUrls: ['./manage-tasks-settings.component.scss'],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule]
|
imports: [NgIf, ReactiveFormsModule, NgbTooltip, NgFor, AsyncPipe, TitleCasePipe, DatePipe, DefaultValuePipe, TranslocoModule, NgTemplateOutlet, TranslocoLocaleModule, UtcToLocalTimePipe]
|
||||||
})
|
})
|
||||||
export class ManageTasksSettingsComponent implements OnInit {
|
export class ManageTasksSettingsComponent implements OnInit {
|
||||||
|
|
||||||
|
@ -430,10 +430,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{{t('added-title')}} {{volume.createdUtc | translocoDate: {dateStyle: 'short' } | defaultDate}}
|
{{t('added-title')}} {{volume.createdUtc | utcToLocalTime | defaultDate}}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
{{t('last-modified-title')}} {{volume.lastModifiedUtc | translocoDate: {dateStyle: 'short' } | defaultDate}}
|
{{t('last-modified-title')}} {{volume.lastModifiedUtc | utcToLocalTime | translocoDate: {dateStyle: 'short' } | defaultDate}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
|
@ -53,7 +53,7 @@ import {ImageComponent} from "../../../shared/image/image.component";
|
|||||||
import {DefaultValuePipe} from "../../../pipe/default-value.pipe";
|
import {DefaultValuePipe} from "../../../pipe/default-value.pipe";
|
||||||
import {TranslocoModule} from "@ngneat/transloco";
|
import {TranslocoModule} from "@ngneat/transloco";
|
||||||
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
|
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
|
||||||
import {Volume} from "../../../_models/volume";
|
import {UtcToLocalTimePipe} from "../../../pipe/utc-to-local-time.pipe";
|
||||||
|
|
||||||
enum TabID {
|
enum TabID {
|
||||||
General = 0,
|
General = 0,
|
||||||
@ -92,7 +92,7 @@ enum TabID {
|
|||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
TranslocoModule,
|
TranslocoModule,
|
||||||
TranslocoDatePipe,
|
TranslocoDatePipe,
|
||||||
|
UtcToLocalTimePipe,
|
||||||
],
|
],
|
||||||
templateUrl: './edit-series-modal.component.html',
|
templateUrl: './edit-series-modal.component.html',
|
||||||
styleUrls: ['./edit-series-modal.component.scss'],
|
styleUrls: ['./edit-series-modal.component.scss'],
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<ng-container *transloco="let t; read: 'bulk-operations'">
|
<ng-container *transloco="let t; read: 'bulk-operations'">
|
||||||
<ng-container *ngIf="bulkSelectionService.selections$ | async as selectionCount">
|
<ng-container *ngIf="bulkSelectionService.selections$ | async as selectionCount">
|
||||||
<div *ngIf="selectionCount > 0" class="bulk-select mb-3 fixed-top" [ngStyle]="{'margin-top': topOffset + 'px'}">
|
<div *ngIf="selectionCount > 0" class="bulk-select mb-3 {{modalMode ? '' : 'fixed-top}}" [ngStyle]="{'margin-top': topOffset + 'px'}">
|
||||||
<div class="d-flex justify-content-around align-items-center">
|
<div class="d-flex justify-content-around align-items-center">
|
||||||
|
|
||||||
<span class="highlight">
|
<span class="highlight">
|
||||||
|
@ -32,19 +32,23 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
|
|||||||
export class BulkOperationsComponent implements OnInit {
|
export class BulkOperationsComponent implements OnInit {
|
||||||
|
|
||||||
@Input({required: true}) actionCallback!: (action: ActionItem<any>, data: any) => void;
|
@Input({required: true}) actionCallback!: (action: ActionItem<any>, data: any) => void;
|
||||||
|
/**
|
||||||
topOffset: number = 56;
|
* Modal mode means don't fix to the top
|
||||||
|
*/
|
||||||
|
@Input() modalMode = false;
|
||||||
|
@Input() topOffset: number = 56;
|
||||||
hasMarkAsRead: boolean = false;
|
hasMarkAsRead: boolean = false;
|
||||||
hasMarkAsUnread: boolean = false;
|
hasMarkAsUnread: boolean = false;
|
||||||
actions: Array<ActionItem<any>> = [];
|
actions: Array<ActionItem<any>> = [];
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||||
|
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||||
|
|
||||||
get Action() {
|
protected readonly Action = Action;
|
||||||
return Action;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(public bulkSelectionService: BulkSelectionService, private readonly cdRef: ChangeDetectorRef,
|
|
||||||
private actionFactoryService: ActionFactoryService) { }
|
constructor() { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.bulkSelectionService.actions$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(actions => {
|
this.bulkSelectionService.actions$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(actions => {
|
||||||
|
@ -4,13 +4,13 @@ import { ReplaySubject } from 'rxjs';
|
|||||||
import {filter} from 'rxjs/operators';
|
import {filter} from 'rxjs/operators';
|
||||||
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
|
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';
|
||||||
|
|
||||||
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark';
|
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
|
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
|
||||||
* This will clear selections between pages.
|
* This will clear selections between pages.
|
||||||
*
|
*
|
||||||
* Remakrs: Page which renders cards is responsible for listening for shift keydown/keyup and updating our state variable.
|
* Remarks: Page which renders cards is responsible for listening for shift keydown/keyup and updating our state variable.
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -151,6 +151,10 @@ export class BulkSelectionService {
|
|||||||
return this.actionFactory.getBookmarkActions(callback);
|
return this.actionFactory.getBookmarkActions(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(this.selectedCards).filter(item => item === 'sideNavStream').length > 0) {
|
||||||
|
return this.applyFilterToList(this.actionFactory.getSideNavStreamActions(callback), [Action.MarkAsInvisible, Action.MarkAsVisible]);
|
||||||
|
}
|
||||||
|
|
||||||
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
|
return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@
|
|||||||
<div class="vr d-none d-lg-block m-2"></div>
|
<div class="vr d-none d-lg-block m-2"></div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<app-icon-and-title [label]="t('date-added-title')" [clickable]="false" fontClasses="fa-solid fa-file-import" [title]="t('date-added-title')">
|
<app-icon-and-title [label]="t('date-added-title')" [clickable]="false" fontClasses="fa-solid fa-file-import" [title]="t('date-added-title')">
|
||||||
{{chapter.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
|
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
|
||||||
</app-icon-and-title>
|
</app-icon-and-title>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -28,11 +28,12 @@ import {MetadataDetailComponent} from "../../series-detail/_components/metadata-
|
|||||||
import {TranslocoModule} from "@ngneat/transloco";
|
import {TranslocoModule} from "@ngneat/transloco";
|
||||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||||
|
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-entity-info-cards',
|
selector: 'app-entity-info-cards',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule],
|
imports: [CommonModule, IconAndTitleComponent, SafeHtmlPipe, DefaultDatePipe, BytesPipe, CompactNumberPipe, AgeRatingPipe, NgbTooltip, MetadataDetailComponent, TranslocoModule, CompactNumberPipe, TranslocoLocaleModule, UtcToLocalTimePipe],
|
||||||
templateUrl: './entity-info-cards.component.html',
|
templateUrl: './entity-info-cards.component.html',
|
||||||
styleUrls: ['./entity-info-cards.component.scss'],
|
styleUrls: ['./entity-info-cards.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
37
UI/Web/src/app/pipe/utc-to-local-time.pipe.ts
Normal file
37
UI/Web/src/app/pipe/utc-to-local-time.pipe.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
type UtcToLocalTimeFormat = 'full' | 'short' | 'shortDate' | 'shortTime';
|
||||||
|
|
||||||
|
// FULL = 'full', // 'EEE, MMMM d, y, h:mm:ss a zzzz' - Monday, June 15, 2015 at 9:03:01 AM GMT+01:00
|
||||||
|
// SHORT = 'short', // 'd/M/yy, h:mm - 15/6/15, 9:03
|
||||||
|
// SHORT_DATE = 'shortDate', // 'd/M/yy' - 15/6/15
|
||||||
|
// SHORT_TIME = 'shortTime', // 'h:mm' - 9:03
|
||||||
|
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'utcToLocalTime',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class UtcToLocalTimePipe implements PipeTransform {
|
||||||
|
|
||||||
|
transform(utcDate: string, format: UtcToLocalTimeFormat = 'short'): string {
|
||||||
|
const browserLanguage = navigator.language;
|
||||||
|
const dateTime = DateTime.fromISO(utcDate, { zone: 'utc' }).toLocal().setLocale(browserLanguage);
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'short':
|
||||||
|
return dateTime.toLocaleString(DateTime.DATETIME_SHORT);
|
||||||
|
case 'shortDate':
|
||||||
|
return dateTime.toLocaleString(DateTime.DATE_MED);
|
||||||
|
case 'shortTime':
|
||||||
|
return dateTime.toLocaleString(DateTime.TIME_SIMPLE);
|
||||||
|
case 'full':
|
||||||
|
return dateTime.toString();
|
||||||
|
default:
|
||||||
|
console.error('No logic in place for utc date format, format: ', format);
|
||||||
|
return utcDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,24 +1,16 @@
|
|||||||
<ng-container *transloco="let t; read: 'draggable-ordered-list'">
|
<ng-container *transloco="let t; read: 'draggable-ordered-list'">
|
||||||
|
|
||||||
<ng-container *ngIf="items.length > 100; else dragList">
|
<ng-container *ngIf="items.length > virtualizeAfter; else dragList">
|
||||||
<div class="example-list list-group-flush">
|
<div class="example-list list-group-flush">
|
||||||
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
|
<virtual-scroller #scroll [items]="items" [bufferAmount]="BufferAmount" [parentScroll]="parentScroll">
|
||||||
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
|
<div class="example-box" *ngFor="let item of scroll.viewPortItems; index as i; trackBy: trackByIdentity">
|
||||||
<div class="d-flex list-container">
|
|
||||||
<div class="me-3 align-middle">
|
|
||||||
<div style="padding-top: 40px">
|
|
||||||
<label for="reorder-{{item.order}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
|
|
||||||
<input *ngIf="accessibilityMode" id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="item.order" style="width: 60px"
|
|
||||||
(focusout)="updateIndex(item.order, item)" (keydown.enter)="updateIndex(item.order, item)" aria-describedby="instructions">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="d-flex list-container">
|
||||||
|
|
||||||
|
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: true }"></ng-container>
|
||||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||||
|
|
||||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
|
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
|
||||||
<i class="fa fa-times" aria-hidden="true"></i>
|
|
||||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</virtual-scroller>
|
</virtual-scroller>
|
||||||
@ -26,29 +18,47 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-template #dragList>
|
<ng-template #dragList>
|
||||||
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
|
<div cdkDropList class="{{items.length > 0 ? 'example-list list-group-flush' : ''}}" (cdkDropListDropped)="drop($event)">
|
||||||
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag [cdkDragData]="item" cdkDragBoundary=".example-list">
|
<div class="example-box" *ngFor="let item of items; index as i" cdkDrag
|
||||||
|
[cdkDragData]="item" cdkDragBoundary=".example-list"
|
||||||
|
[cdkDragDisabled]="accessibilityMode || disabled || bulkMode" cdkDragPreviewContainer="parent">
|
||||||
<div class="d-flex list-container">
|
<div class="d-flex list-container">
|
||||||
<div class="me-3 align-middle">
|
|
||||||
<div class="align-middle" style="padding-top: 40px" *ngIf="accessibilityMode">
|
|
||||||
<label for="reorder-{{i}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
|
|
||||||
<input id="reorder-{{i}}" class="form-control" type="number" inputmode="numeric" min="0" [max]="items.length - 1" [value]="i" style="width: 60px"
|
|
||||||
(focusout)="updateIndex(i, item)" (keydown.enter)="updateIndex(i, item)" aria-describedby="instructions">
|
|
||||||
</div>
|
|
||||||
<i *ngIf="!accessibilityMode && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<ng-container [ngTemplateOutlet]="handle" [ngTemplateOutletContext]="{ $implicit: item, idx: i, isVirtualized: false }"></ng-container>
|
||||||
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
|
||||||
|
|
||||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
|
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
|
||||||
<i class="fa fa-times" aria-hidden="true"></i>
|
|
||||||
<span class="visually-hidden" attr.aria-labelledby="item.id--{{i}}">{{t('remove-item-alt')}}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #removeBtn let-item let-idx>
|
||||||
|
<button class="btn btn-icon float-end" (click)="removeItem(item, idx)" *ngIf="showRemoveButton" [disabled]="disabled">
|
||||||
|
<i class="fa fa-times" aria-hidden="true"></i>
|
||||||
|
<span class="visually-hidden" attr.aria-labelledby="item.id--{{idx}}">{{t('remove-item-alt')}}</span>
|
||||||
|
</button>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #handle let-item let-idx="idx" let-isVirtualized="isVirtualized">
|
||||||
|
<div class="me-3 align-middle">
|
||||||
|
<div class="align-middle" [ngClass]="{'accessibility-padding': accessibilityMode, 'bulk-padding': bulkMode}" *ngIf="accessibilityMode || bulkMode">
|
||||||
|
<ng-container *ngIf="accessibilityMode">
|
||||||
|
<label for="reorder-{{idx}}" class="form-label visually-hidden">{{t('reorder-label')}}</label>
|
||||||
|
<input id="reorder-{{idx}}" class="form-control manual-input" type="number" inputmode="numeric" min="0"
|
||||||
|
[max]="items.length - 1" [value]="idx"
|
||||||
|
(focusout)="updateIndex(idx, item)" (keydown.enter)="updateIndex(idx, item)" aria-describedby="instructions">
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="bulkMode">
|
||||||
|
<label for="select-{{idx}}" class="form-label visually-hidden">{{t('bulk-select-label')}}</label>
|
||||||
|
<input id="select-{{idx}}" class="form-check-input mt-0" type="checkbox" (change)="selectItem($event, item, idx)"
|
||||||
|
[value]="bulkSelectionService.isCardSelected('sideNavStream', idx)">
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<i *ngIf="!isVirtualized && !(accessibilityMode || bulkMode) && !disabled" class="fa fa-grip-vertical drag-handle" aria-hidden="true" cdkDragHandle></i>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
<p class="visually-hidden" id="instructions">
|
<p class="visually-hidden" id="instructions">
|
||||||
{{t('instructions-alt')}}
|
{{t('instructions-alt')}}
|
||||||
|
@ -61,6 +61,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.accessibility-padding {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.bulk-padding {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.virtual-scroller, virtual-scroller {
|
.virtual-scroller, virtual-scroller {
|
||||||
@ -71,3 +77,7 @@
|
|||||||
virtual-scroller.empty {
|
virtual-scroller.empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.manual-input {
|
||||||
|
width: 83px;
|
||||||
|
}
|
||||||
|
@ -1,8 +1,22 @@
|
|||||||
import { CdkDragDrop, moveItemInArray, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
|
import { CdkDragDrop, moveItemInArray, CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
|
||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, EventEmitter, Input, Output, TemplateRef, TrackByFunction } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
ContentChild,
|
||||||
|
EventEmitter,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
TemplateRef,
|
||||||
|
TrackByFunction
|
||||||
|
} from '@angular/core';
|
||||||
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
|
||||||
import { NgIf, NgFor, NgTemplateOutlet } from '@angular/common';
|
import {NgIf, NgFor, NgTemplateOutlet, NgClass} from '@angular/common';
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
|
||||||
|
import {SeriesCardComponent} from "../../../cards/series-card/series-card.component";
|
||||||
|
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||||
|
|
||||||
export interface IndexUpdateEvent {
|
export interface IndexUpdateEvent {
|
||||||
fromPosition: number;
|
fromPosition: number;
|
||||||
@ -22,10 +36,14 @@ export interface ItemRemoveEvent {
|
|||||||
styleUrls: ['./draggable-ordered-list.component.scss'],
|
styleUrls: ['./draggable-ordered-list.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, CdkDragHandle, TranslocoDirective]
|
imports: [NgIf, VirtualScrollerModule, NgFor, NgTemplateOutlet, CdkDropList, CdkDrag, CdkDragHandle, TranslocoDirective, NgClass, SeriesCardComponent]
|
||||||
})
|
})
|
||||||
export class DraggableOrderedListComponent {
|
export class DraggableOrderedListComponent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After this many elements, drag and drop is disabled and we use a virtualized list instead
|
||||||
|
*/
|
||||||
|
@Input() virtualizeAfter = 100;
|
||||||
@Input() accessibilityMode: boolean = false;
|
@Input() accessibilityMode: boolean = false;
|
||||||
/**
|
/**
|
||||||
* Shows the remove button on the list item
|
* Shows the remove button on the list item
|
||||||
@ -40,11 +58,17 @@ export class DraggableOrderedListComponent {
|
|||||||
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
|
* Disables drag and drop functionality. Useful if a filter is present which will skew actual index.
|
||||||
*/
|
*/
|
||||||
@Input() disabled: boolean = false;
|
@Input() disabled: boolean = false;
|
||||||
|
/**
|
||||||
|
* When enabled, draggability is disabled and a checkbox renders instead of order box or drag handle
|
||||||
|
*/
|
||||||
|
@Input() bulkMode: boolean = false;
|
||||||
@Input() trackByIdentity: TrackByFunction<any> = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`;
|
@Input() trackByIdentity: TrackByFunction<any> = (index: number, item: any) => `${item.id}_${item.order}_${item.title}`;
|
||||||
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
|
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
|
||||||
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
|
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
|
||||||
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
|
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
|
||||||
|
|
||||||
|
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||||
|
|
||||||
get BufferAmount() {
|
get BufferAmount() {
|
||||||
return Math.min(this.items.length / 20, 20);
|
return Math.min(this.items.length / 20, 20);
|
||||||
}
|
}
|
||||||
@ -85,4 +109,11 @@ export class DraggableOrderedListComponent {
|
|||||||
});
|
});
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectItem(updatedVal: Event, item: SideNavStream, index: number) {
|
||||||
|
const boolVal = (updatedVal.target as HTMLInputElement).value == 'true';
|
||||||
|
|
||||||
|
this.bulkSelectionService.handleCardSelection('sideNavStream', index, this.items.length, boolVal);
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
<h4 class="modal-title">{{t('title-' + activeTab)}}</h4>
|
<h4 class="modal-title">{{t('title-' + activeTab)}}</h4>
|
||||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
<div #modalBody class="modal-body scrollable-modal {{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? '' : 'd-flex'}}">
|
||||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 154px;">
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 154px;">
|
||||||
<li [ngbNavItem]="TabID.Dashboard">
|
<li [ngbNavItem]="TabID.Dashboard">
|
||||||
<a ngbNavLink>{{t(TabID.Dashboard)}}</a>
|
<a ngbNavLink>{{t(TabID.Dashboard)}}</a>
|
||||||
|
@ -49,6 +49,12 @@ export class CustomizeDashboardStreamsComponent {
|
|||||||
constructor(public modal: NgbActiveModal) {
|
constructor(public modal: NgbActiveModal) {
|
||||||
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
|
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
|
||||||
this.items = results[0];
|
this.items = results[0];
|
||||||
|
|
||||||
|
// After 100 items, drag and drop is disabled to use virtualization
|
||||||
|
if (this.items.length > 100) {
|
||||||
|
this.accessibilityMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
|
const smartFilterStreams = new Set(results[0].filter(d => !d.isProvided).map(d => d.name));
|
||||||
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
|
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
@ -10,14 +10,25 @@
|
|||||||
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
|
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<div class="form-check form-check-inline" style="margin-top: 35px; margin-left: 10px">
|
<form [formGroup]="pageOperationsForm">
|
||||||
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
|
<div class="form-check form-check-inline" style="margin-top: 23px; margin-left: 10px">
|
||||||
|
<input class="form-check-input" type="checkbox" id="accessibility-mode" formControlName="accessibilityMode">
|
||||||
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
|
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check form-check-inline" style="margin-left: 10px">
|
||||||
|
<input class="form-check-input" type="checkbox" id="bulk-mode" formControlName="bulkMode" >
|
||||||
|
<label class="form-check-label" for="bulk-mode">{{t('bulk-mode-label')}}</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
|
<app-bulk-operations [modalMode]="true" [topOffset]="0" [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||||
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value">
|
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)"
|
||||||
|
[accessibilityMode]="pageOperationsForm.get('accessibilityMode')!.value"
|
||||||
|
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value"
|
||||||
|
[bulkMode]="pageOperationsForm.get('bulkMode')!.value"
|
||||||
|
[virtualizeAfter]="100"
|
||||||
|
>
|
||||||
<ng-template #draggableItem let-position="idx" let-item>
|
<ng-template #draggableItem let-position="idx" let-item>
|
||||||
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
|
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject} from '@angular/core';
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnDestroy} from '@angular/core';
|
||||||
import {CommonModule} from '@angular/common';
|
import {CommonModule} from '@angular/common';
|
||||||
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
import {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||||
import {FilterService} from "../../../_services/filter.service";
|
import {FilterService} from "../../../_services/filter.service";
|
||||||
@ -11,36 +11,43 @@ import {
|
|||||||
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||||
import {NavService} from "../../../_services/nav.service";
|
import {NavService} from "../../../_services/nav.service";
|
||||||
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
|
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
|
||||||
import {CommonStream} from "../../../_models/common-stream";
|
|
||||||
import {TranslocoDirective} from "@ngneat/transloco";
|
import {TranslocoDirective} from "@ngneat/transloco";
|
||||||
import {SidenavStreamListItemComponent} from "../sidenav-stream-list-item/sidenav-stream-list-item.component";
|
import {SidenavStreamListItemComponent} from "../sidenav-stream-list-item/sidenav-stream-list-item.component";
|
||||||
import {ExternalSourceService} from "../../../external-source.service";
|
import {ExternalSourceService} from "../../../external-source.service";
|
||||||
import {ExternalSource} from "../../../_models/sidenav/external-source";
|
import {ExternalSource} from "../../../_models/sidenav/external-source";
|
||||||
import {StreamType} from "../../../_models/dashboard/stream-type.enum";
|
|
||||||
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
import {SideNavStreamType} from "../../../_models/sidenav/sidenav-stream-type.enum";
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||||
import {FilterPipe} from "../../../pipe/filter.pipe";
|
import {FilterPipe} from "../../../pipe/filter.pipe";
|
||||||
|
import {BulkOperationsComponent} from "../../../cards/bulk-operations/bulk-operations.component";
|
||||||
|
import {Action, ActionItem} from "../../../_services/action-factory.service";
|
||||||
|
import {BulkSelectionService} from "../../../cards/bulk-selection.service";
|
||||||
|
import {filter, tap} from "rxjs/operators";
|
||||||
|
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-customize-sidenav-streams',
|
selector: 'app-customize-sidenav-streams',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, SidenavStreamListItemComponent, ReactiveFormsModule, FilterPipe],
|
imports: [CommonModule, DraggableOrderedListComponent, DashboardStreamListItemComponent, TranslocoDirective, SidenavStreamListItemComponent, ReactiveFormsModule, FilterPipe, BulkOperationsComponent],
|
||||||
templateUrl: './customize-sidenav-streams.component.html',
|
templateUrl: './customize-sidenav-streams.component.html',
|
||||||
styleUrls: ['./customize-sidenav-streams.component.scss'],
|
styleUrls: ['./customize-sidenav-streams.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CustomizeSidenavStreamsComponent {
|
export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||||
|
|
||||||
|
//@Input({required: true}) parentScrollElem!: Element | Window;
|
||||||
items: SideNavStream[] = [];
|
items: SideNavStream[] = [];
|
||||||
smartFilters: SmartFilter[] = [];
|
smartFilters: SmartFilter[] = [];
|
||||||
externalSources: ExternalSource[] = [];
|
externalSources: ExternalSource[] = [];
|
||||||
accessibilityMode: boolean = false;
|
|
||||||
|
|
||||||
listForm: FormGroup = new FormGroup({
|
listForm: FormGroup = new FormGroup({
|
||||||
'filterSideNavStream': new FormControl('', []),
|
'filterSideNavStream': new FormControl('', []),
|
||||||
'filterSmartFilter': new FormControl('', []),
|
'filterSmartFilter': new FormControl('', []),
|
||||||
'filterExternalSource': new FormControl('', []),
|
'filterExternalSource': new FormControl('', []),
|
||||||
});
|
});
|
||||||
|
pageOperationsForm: FormGroup = new FormGroup({
|
||||||
|
'accessibilityMode': new FormControl(false, []),
|
||||||
|
'bulkMode': new FormControl(false, [])
|
||||||
|
})
|
||||||
|
|
||||||
filterSideNavStreams = (listItem: SideNavStream) => {
|
filterSideNavStreams = (listItem: SideNavStream) => {
|
||||||
const filterVal = (this.listForm.value.filterSideNavStream || '').toLowerCase();
|
const filterVal = (this.listForm.value.filterSideNavStream || '').toLowerCase();
|
||||||
@ -57,17 +64,87 @@ export class CustomizeSidenavStreamsComponent {
|
|||||||
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
|
return listItem.name.toLowerCase().indexOf(filterVal) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bulkActionCallback = (action: ActionItem<SideNavStream>, data: SideNavStream) => {
|
||||||
|
const streams = this.bulkSelectionService.getSelectedCardsForSource('sideNavStream').map(index => this.items[parseInt(index, 10)]);
|
||||||
|
let visibleState = false;
|
||||||
|
switch (action.action) {
|
||||||
|
case Action.MarkAsVisible:
|
||||||
|
visibleState = true;
|
||||||
|
break;
|
||||||
|
case Action.MarkAsInvisible:
|
||||||
|
visibleState = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for(let index of this.bulkSelectionService.getSelectedCardsForSource('sideNavStream').map(s => parseInt(s, 10))) {
|
||||||
|
this.items[index].visible = visibleState;
|
||||||
|
this.items[index] = {...this.items[index]};
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
// Make bulk call
|
||||||
|
this.sideNavService.bulkToggleSideNavStreamVisibility(streams.map(s => s.id), visibleState).subscribe(() => this.bulkSelectionService.deselectAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private readonly sideNavService = inject(NavService);
|
private readonly sideNavService = inject(NavService);
|
||||||
private readonly filterService = inject(FilterService);
|
private readonly filterService = inject(FilterService);
|
||||||
private readonly externalSourceService = inject(ExternalSourceService);
|
private readonly externalSourceService = inject(ExternalSourceService);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly bulkSelectionService = inject(BulkSelectionService);
|
||||||
|
|
||||||
constructor(public modal: NgbActiveModal) {
|
constructor(public modal: NgbActiveModal) {
|
||||||
|
|
||||||
|
this.pageOperationsForm.get('accessibilityMode')?.valueChanges.pipe(
|
||||||
|
tap(_ => {
|
||||||
|
const accessibleValue = this.pageOperationsForm.get('accessibilityMode')?.value;
|
||||||
|
if (accessibleValue) {
|
||||||
|
if (this.pageOperationsForm.get('bulkMode')?.disabled) return;
|
||||||
|
this.pageOperationsForm.get('bulkMode')?.disable();
|
||||||
|
} else {
|
||||||
|
if (!this.pageOperationsForm.get('bulkMode')?.disabled) return;
|
||||||
|
this.pageOperationsForm.get('bulkMode')?.enable();
|
||||||
|
}
|
||||||
|
this.cdRef.markForCheck();
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
this.pageOperationsForm.get('bulkMode')?.valueChanges.pipe(
|
||||||
|
tap(_ => {
|
||||||
|
const bulkValue = this.pageOperationsForm.get('bulkMode')?.value;
|
||||||
|
if (bulkValue) {
|
||||||
|
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
||||||
|
this.pageOperationsForm.get('accessibilityMode')?.disable();
|
||||||
|
} else {
|
||||||
|
if (this.pageOperationsForm.get('accessibilityMode')?.disabled) return;
|
||||||
|
this.pageOperationsForm.get('accessibilityMode')?.enable();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
|
this.pageOperationsForm.valueChanges.pipe(
|
||||||
|
tap(_ => {
|
||||||
|
if (this.pageOperationsForm.value.accessibilityMode || this.pageOperationsForm.value.bulkMode) {
|
||||||
|
this.listForm.get('filterSideNavStream')?.disable();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.listForm.get('filterSideNavStream')?.enable();
|
||||||
|
}),
|
||||||
|
takeUntilDestroyed(this.destroyRef)
|
||||||
|
).subscribe();
|
||||||
|
|
||||||
forkJoin([this.sideNavService.getSideNavStreams(false),
|
forkJoin([this.sideNavService.getSideNavStreams(false),
|
||||||
this.filterService.getAllFilters(), this.externalSourceService.getExternalSources()
|
this.filterService.getAllFilters(), this.externalSourceService.getExternalSources()
|
||||||
]).subscribe(results => {
|
]).subscribe(results => {
|
||||||
this.items = results[0];
|
this.items = results[0];
|
||||||
|
|
||||||
|
// After 100 items, drag and drop is disabled to use virtualization
|
||||||
|
if (this.items.length > 100) {
|
||||||
|
this.pageOperationsForm.get('accessibilityMode')?.setValue(true);
|
||||||
|
}
|
||||||
|
|
||||||
const existingSmartFilterStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
|
const existingSmartFilterStreams = new Set(results[0].filter(d => !d.isProvided && d.streamType === SideNavStreamType.SmartFilter).map(d => d.name));
|
||||||
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
|
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
|
||||||
|
|
||||||
@ -77,6 +154,10 @@ export class CustomizeSidenavStreamsComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.bulkSelectionService.deselectAll();
|
||||||
|
}
|
||||||
|
|
||||||
resetSideNavFilter() {
|
resetSideNavFilter() {
|
||||||
this.listForm.get('filterSideNavStream')?.setValue('');
|
this.listForm.get('filterSideNavStream')?.setValue('');
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
@ -108,11 +189,6 @@ export class CustomizeSidenavStreamsComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAccessibilityMode() {
|
|
||||||
this.accessibilityMode = !this.accessibilityMode;
|
|
||||||
this.cdRef.markForCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
orderUpdated(event: IndexUpdateEvent) {
|
orderUpdated(event: IndexUpdateEvent) {
|
||||||
this.sideNavService.updateSideNavStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe(() => {
|
this.sideNavService.updateSideNavStreamPosition(event.item.name, event.item.id, event.fromPosition, event.toPosition).subscribe(() => {
|
||||||
@ -128,8 +204,8 @@ export class CustomizeSidenavStreamsComponent {
|
|||||||
updateVisibility(item: SideNavStream, position: number) {
|
updateVisibility(item: SideNavStream, position: number) {
|
||||||
const stream = this.items.filter(s => s.id == item.id)[0];
|
const stream = this.items.filter(s => s.id == item.id)[0];
|
||||||
stream.visible = !stream.visible;
|
stream.visible = !stream.visible;
|
||||||
this.sideNavService.updateSideNavStream(stream).subscribe();
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
this.sideNavService.updateSideNavStream(stream).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -24,5 +24,10 @@
|
|||||||
(sourceDelete)="deleteSource(idx, $event)"
|
(sourceDelete)="deleteSource(idx, $event)"
|
||||||
[isViewMode]="externalSource.id !== 0"></app-edit-external-source-item>
|
[isViewMode]="externalSource.id !== 0"></app-edit-external-source-item>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ul>
|
||||||
|
<li class="list-group-item" *ngIf="externalSources.length === 0">
|
||||||
|
{{t('no-data')}}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
ul {
|
||||||
|
margin:0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 5px 0;
|
||||||
|
color: var(--list-group-hover-text-color);
|
||||||
|
background-color: var(--card-bg-color);
|
||||||
|
|
||||||
|
span {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,7 +12,7 @@ ul {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
color: var(--list-group-hover-text-color);
|
color: var(--list-group-hover-text-color);
|
||||||
background-color: var(--list-group-hover-bg-color);
|
background-color: var(--card-bg-color);
|
||||||
|
|
||||||
span {
|
span {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -159,11 +159,11 @@ export class SideNavComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleHomeActions() {
|
handleHomeActions() {
|
||||||
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
|
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||||
}
|
}
|
||||||
|
|
||||||
importCbl() {
|
importCbl() {
|
||||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
|
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||||
}
|
}
|
||||||
|
|
||||||
performAction(action: ActionItem<Library>, library: Library) {
|
performAction(action: ActionItem<Library>, library: Library) {
|
||||||
|
@ -2,7 +2,7 @@ import {
|
|||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef, importProvidersFrom,
|
||||||
inject,
|
inject,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
@ -49,6 +49,11 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
|
|||||||
import {LocalizationService} from "../../_services/localization.service";
|
import {LocalizationService} from "../../_services/localization.service";
|
||||||
import {Language} from "../../_models/metadata/language";
|
import {Language} from "../../_models/metadata/language";
|
||||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||||
|
import {
|
||||||
|
provideTranslocoPersistTranslations,
|
||||||
|
TranslocoPersistTranslations
|
||||||
|
} from "@ngneat/transloco-persist-translations";
|
||||||
|
import {HttpLoader} from "../../../httpLoader";
|
||||||
|
|
||||||
enum AccordionPanelID {
|
enum AccordionPanelID {
|
||||||
ImageReader = 'image-reader',
|
ImageReader = 'image-reader',
|
||||||
@ -76,7 +81,7 @@ enum FragmentID {
|
|||||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||||
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
||||||
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
||||||
TranslocoDirective]
|
TranslocoDirective],
|
||||||
})
|
})
|
||||||
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
@ -114,20 +119,22 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
opdsEnabled: boolean = false;
|
opdsEnabled: boolean = false;
|
||||||
opdsUrl: string = '';
|
opdsUrl: string = '';
|
||||||
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
|
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
|
||||||
|
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly accountService = inject(AccountService);
|
||||||
get AccordionPanelID() {
|
private readonly toastr = inject(ToastrService);
|
||||||
return AccordionPanelID;
|
private readonly bookService = inject(BookService);
|
||||||
}
|
private readonly titleService = inject(Title);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
get FragmentID() {
|
private readonly settingsService = inject(SettingsService);
|
||||||
return FragmentID;
|
private readonly router = inject(Router);
|
||||||
}
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
|
private readonly localizationService = inject(LocalizationService);
|
||||||
|
protected readonly AccordionPanelID = AccordionPanelID;
|
||||||
|
protected readonly FragmentID = FragmentID;
|
||||||
|
|
||||||
|
|
||||||
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
|
constructor() {
|
||||||
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
|
|
||||||
private router: Router, private readonly cdRef: ChangeDetectorRef, public localizationService: LocalizationService) {
|
|
||||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
|
|
||||||
@ -306,6 +313,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
|||||||
this.toastr.success(translate('user-preferences.success-toast'));
|
this.toastr.success(translate('user-preferences.success-toast'));
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
this.user.preferences = updatedPrefs;
|
this.user.preferences = updatedPrefs;
|
||||||
|
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
|
@ -1315,6 +1315,7 @@
|
|||||||
"draggable-ordered-list": {
|
"draggable-ordered-list": {
|
||||||
"instructions-alt": "When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated.",
|
"instructions-alt": "When you put a number in the reorder input, the item will be inserted at that location and all other items will have their order updated.",
|
||||||
"reorder-label": "Reorder",
|
"reorder-label": "Reorder",
|
||||||
|
"bulk-select-label": "Bulk Select item",
|
||||||
"remove-item-alt": "Remove item"
|
"remove-item-alt": "Remove item"
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1771,15 +1772,17 @@
|
|||||||
"smart-filters-title": "Smart Filters",
|
"smart-filters-title": "Smart Filters",
|
||||||
"external-sources-title": "{{customize-dashboard-modal.external-sources}}",
|
"external-sources-title": "{{customize-dashboard-modal.external-sources}}",
|
||||||
"reorder-when-filter-present": "You cannot reorder items via drag & drop while a filter is present. Use {{customize-sidenav-streams.order-numbers-label}}",
|
"reorder-when-filter-present": "You cannot reorder items via drag & drop while a filter is present. Use {{customize-sidenav-streams.order-numbers-label}}",
|
||||||
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}"
|
"order-numbers-label": "{{reading-list-detail.order-numbers-label}}",
|
||||||
|
"bulk-mode-label": "Bulk Mode"
|
||||||
},
|
},
|
||||||
|
|
||||||
"manage-external-sources": {
|
"manage-external-sources": {
|
||||||
"add-source": "Add",
|
"add-source": "Add",
|
||||||
"help-link": "More information",
|
"help-link": "More information",
|
||||||
"description": "Add External Servers to your account and then add them to your Side Nav for a quick way to switch between your and your friend's server.",
|
"description": "Enhance your experience by adding external servers and conveniently include them in your Side Nav for quick access to both your server and your friend's server.",
|
||||||
"clear": "{{common.clear}}",
|
"clear": "{{common.clear}}",
|
||||||
"filter": "{{common.filter}}"
|
"filter": "{{common.filter}}",
|
||||||
|
"no-data": "No External Sources exist"
|
||||||
},
|
},
|
||||||
|
|
||||||
"manage-smart-filters": {
|
"manage-smart-filters": {
|
||||||
@ -1978,7 +1981,9 @@
|
|||||||
"add-rule-group-and": "Add Rule Group (AND)",
|
"add-rule-group-and": "Add Rule Group (AND)",
|
||||||
"add-rule-group-or": "Add Rule Group (OR)",
|
"add-rule-group-or": "Add Rule Group (OR)",
|
||||||
"remove-rule-group": "Remove Rule Group",
|
"remove-rule-group": "Remove Rule Group",
|
||||||
"customize": "Customize"
|
"customize": "Customize",
|
||||||
|
"mark-visible": "Mark as Visible",
|
||||||
|
"mark-invisible": "Mark as Invisible"
|
||||||
},
|
},
|
||||||
|
|
||||||
"preferences": {
|
"preferences": {
|
||||||
|
@ -15,7 +15,7 @@ import { JwtInterceptor } from './app/_interceptors/jwt.interceptor';
|
|||||||
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
|
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
|
||||||
import {HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient} from '@angular/common/http';
|
import {HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient} from '@angular/common/http';
|
||||||
import {
|
import {
|
||||||
provideTransloco,
|
provideTransloco, TranslocoConfig,
|
||||||
TranslocoService
|
TranslocoService
|
||||||
} from "@ngneat/transloco";
|
} from "@ngneat/transloco";
|
||||||
import {environment} from "./environments/environment";
|
import {environment} from "./environments/environment";
|
||||||
@ -96,7 +96,7 @@ const languageCodes = [
|
|||||||
'syr', 'syr-SY', 'ta', 'ta-IN', 'te', 'te-IN', 'th', 'th-TH', 'tl', 'tl-PH', 'tn',
|
'syr', 'syr-SY', 'ta', 'ta-IN', 'te', 'te-IN', 'th', 'th-TH', 'tl', 'tl-PH', 'tn',
|
||||||
'tn-ZA', 'tr', 'tr-TR', 'tt', 'tt-RU', 'ts', 'uk', 'uk-UA', 'ur', 'ur-PK', 'uz',
|
'tn-ZA', 'tr', 'tr-TR', 'tt', 'tt-RU', 'ts', 'uk', 'uk-UA', 'ur', 'ur-PK', 'uz',
|
||||||
'uz-UZ', 'uz-UZ', 'vi', 'vi-VN', 'xh', 'xh-ZA', 'zh', 'zh-CN', 'zh-HK', 'zh-MO',
|
'uz-UZ', 'uz-UZ', 'vi', 'vi-VN', 'xh', 'xh-ZA', 'zh', 'zh-CN', 'zh-HK', 'zh-MO',
|
||||||
'zh-SG', 'zh-TW', 'zu', 'zu-ZA', 'zh_Hans',
|
'zh-SG', 'zh-TW', 'zu', 'zu-ZA', 'zh_Hans', 'zh_Hant',
|
||||||
];
|
];
|
||||||
|
|
||||||
const translocoOptions = {
|
const translocoOptions = {
|
||||||
@ -109,8 +109,8 @@ const translocoOptions = {
|
|||||||
missingHandler: {
|
missingHandler: {
|
||||||
useFallbackTranslation: true,
|
useFallbackTranslation: true,
|
||||||
allowEmpty: false,
|
allowEmpty: false,
|
||||||
}
|
},
|
||||||
}
|
} as TranslocoConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, {
|
||||||
@ -133,7 +133,8 @@ bootstrapApplication(AppComponent, {
|
|||||||
}),
|
}),
|
||||||
provideTranslocoPersistTranslations({
|
provideTranslocoPersistTranslations({
|
||||||
loader: HttpLoader,
|
loader: HttpLoader,
|
||||||
storage: { useValue: localStorage }
|
storage: { useValue: localStorage },
|
||||||
|
ttl: 604800
|
||||||
}),
|
}),
|
||||||
provideTranslocoPersistLang({
|
provideTranslocoPersistLang({
|
||||||
storage: {
|
storage: {
|
||||||
|
74
openapi.json
74
openapi.json
@ -7,7 +7,7 @@
|
|||||||
"name": "GPL-3.0",
|
"name": "GPL-3.0",
|
||||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||||
},
|
},
|
||||||
"version": "0.7.8.8"
|
"version": "0.7.8.10"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
@ -285,6 +285,7 @@
|
|||||||
"Account"
|
"Account"
|
||||||
],
|
],
|
||||||
"summary": "Resets the API Key assigned with a user",
|
"summary": "Resets the API Key assigned with a user",
|
||||||
|
"description": "This will log unauthorized requests to Security log",
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
@ -3541,6 +3542,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Opds/{apiKey}/external-sources": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Opds"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "apiKey",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Opds/{apiKey}/libraries": {
|
"/api/Opds/{apiKey}/libraries": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -4202,6 +4225,7 @@
|
|||||||
"Plugin"
|
"Plugin"
|
||||||
],
|
],
|
||||||
"summary": "Returns the version of the Kavita install",
|
"summary": "Returns the version of the Kavita install",
|
||||||
|
"description": "This will log unauthorized requests to Security log",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "apiKey",
|
"name": "apiKey",
|
||||||
@ -10937,6 +10961,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/Stream/bulk-sidenav-stream-visibility": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Stream"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BulkUpdateSideNavStreamVisibilityDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BulkUpdateSideNavStreamVisibilityDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/*+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BulkUpdateSideNavStreamVisibilityDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/Tachiyomi/latest-chapter": {
|
"/api/Tachiyomi/latest-chapter": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -13108,6 +13163,23 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"BulkUpdateSideNavStreamVisibilityDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"CblBookResult": {
|
"CblBookResult": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user