mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Polish for Release (#2314)
This commit is contained in:
parent
fe4af4b648
commit
59b950c4bd
@ -10,8 +10,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.8" />
|
||||
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.8" />
|
||||
<PackageReference Include="BenchmarkDotNet" Version="0.13.9" />
|
||||
<PackageReference Include="BenchmarkDotNet.Annotations" Version="0.13.9" />
|
||||
<PackageReference Include="NSubstitute" Version="5.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
@ -6,13 +6,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<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="NSubstitute" Version="5.1.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" 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.runner.visualstudio" Version="2.5.1">
|
||||
<PackageReference Include="xunit" Version="2.5.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
@ -68,22 +68,22 @@
|
||||
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.3.4" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.53" />
|
||||
<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.OpenIdConnect" Version="7.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.12" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="7.0.12" />
|
||||
<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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</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.IO.RecyclableMemoryStream" Version="2.3.2" />
|
||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
|
||||
<PackageReference Include="NetVips" Version="2.3.1" />
|
||||
<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.AspNetCore" Version="7.0.0" />
|
||||
<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.File" Version="5.0.0" />
|
||||
<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="SonarAnalyzer.CSharp" Version="9.10.0.77988">
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="9.12.0.78982">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||
<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.Drawing.Common" Version="7.0.0" />
|
||||
<PackageReference Include="VersOne.Epub" Version="3.3.1" />
|
||||
|
@ -306,12 +306,13 @@ public class AccountController : BaseApiController
|
||||
/// <summary>
|
||||
/// Resets the API Key assigned with a user
|
||||
/// </summary>
|
||||
/// <remarks>This will log unauthorized requests to Security log</remarks>
|
||||
/// <returns></returns>
|
||||
[HttpPost("reset-api-key")]
|
||||
public async Task<ActionResult<string>> ResetApiKey()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
if (user == null) return Unauthorized();
|
||||
if (user == null) throw new KavitaUnauthenticatedUserException();
|
||||
|
||||
user.ApiKey = HashUtil.ApiKey();
|
||||
|
||||
|
@ -98,8 +98,11 @@ public class LibraryController : BaseApiController
|
||||
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))
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.ToList();
|
||||
@ -119,10 +122,8 @@ public class LibraryController : BaseApiController
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
}
|
||||
|
||||
|
||||
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();
|
||||
_taskScheduler.ScanLibrary(library.Id);
|
||||
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
|
||||
|
@ -216,19 +216,41 @@ public class OpdsController : BaseApiController
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
|
||||
}
|
||||
});
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
|
||||
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
|
||||
{
|
||||
Id = "allSmartFilters",
|
||||
Title = await _localizationService.Translate(userId, "smart-filters"),
|
||||
Content = new FeedEntryContent()
|
||||
feed.Entries.Add(new FeedEntry()
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-smart-filters")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
|
||||
}
|
||||
});
|
||||
Id = "allSmartFilters",
|
||||
Title = await _localizationService.Translate(userId, "smart-filters"),
|
||||
Content = new FeedEntryContent()
|
||||
{
|
||||
Text = await _localizationService.Translate(userId, "browse-smart-filters")
|
||||
},
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
@ -306,6 +328,38 @@ public class OpdsController : BaseApiController
|
||||
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")]
|
||||
[Produces("application/xml")]
|
||||
@ -318,12 +372,16 @@ public class OpdsController : BaseApiController
|
||||
var libraries = await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId);
|
||||
var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{prefix}{apiKey}/libraries", apiKey, prefix);
|
||||
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()
|
||||
{
|
||||
Id = library.Id.ToString(),
|
||||
Title = library.Name,
|
||||
Id = library!.Id.ToString(),
|
||||
Title = library.Name!,
|
||||
Links = new List<FeedLink>()
|
||||
{
|
||||
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.
|
||||
/// </summary>
|
||||
/// <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="pluginName">Name of the Plugin</param>
|
||||
/// <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
|
||||
// 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);
|
||||
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);
|
||||
_logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName, user!.UserName, userId);
|
||||
return new UserDto
|
||||
@ -54,6 +66,7 @@ public class PluginController : BaseApiController
|
||||
/// <summary>
|
||||
/// Returns the version of the Kavita install
|
||||
/// </summary>
|
||||
/// <remarks>This will log unauthorized requests to Security log</remarks>
|
||||
/// <param name="apiKey">Required for authenticating to get result</param>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
@ -61,7 +74,7 @@ public class PluginController : BaseApiController
|
||||
public async Task<ActionResult<string>> GetVersion([Required] string 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);
|
||||
}
|
||||
}
|
||||
|
@ -111,6 +111,7 @@ public class ScrobblingController : BaseApiController
|
||||
pagination ??= UserParams.Default;
|
||||
var events = await _unitOfWork.ScrobbleRepository.GetUserEvents(User.GetUserId(), filter, pagination);
|
||||
Response.AddPaginationHeader(events.CurrentPage, events.PageSize, events.TotalCount, events.TotalPages);
|
||||
|
||||
return Ok(events);
|
||||
}
|
||||
|
||||
|
@ -183,4 +183,11 @@ public class StreamController : BaseApiController
|
||||
await _streamService.UpdateSideNavStreamPosition(User.GetUserId(), dto);
|
||||
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<string> GetLocale(int userId);
|
||||
Task<IList<DashboardStreamDto>> GetDashboardStreams(int userId, bool visibleOnly = false);
|
||||
Task<IList<AppUserDashboardStream>> GetAllDashboardStreams();
|
||||
Task<AppUserDashboardStream?> GetDashboardStream(int streamId);
|
||||
Task<IList<AppUserDashboardStream>> GetDashboardStreamWithFilter(int filterId);
|
||||
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>> GetSideNavStreamsByLibraryId(int libraryId);
|
||||
Task<IList<AppUserSideNavStream>> GetSideNavStreamWithExternalSource(int externalSourceId);
|
||||
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
@ -356,6 +358,13 @@ public class UserRepository : IUserRepository
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<IList<AppUserDashboardStream>> GetAllDashboardStreams()
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
.OrderBy(d => d.Order)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUserDashboardStream?> GetDashboardStream(int streamId)
|
||||
{
|
||||
return await _context.AppUserDashboardStream
|
||||
@ -453,6 +462,13 @@ public class UserRepository : IUserRepository
|
||||
.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()
|
||||
{
|
||||
|
@ -151,6 +151,8 @@
|
||||
"collections": "All Collections",
|
||||
"browse-collections": "Browse by Collections",
|
||||
"smart-filters": "Smart Filters",
|
||||
"external-sources": "External Sources",
|
||||
"browse-external-sources": "Browse External Sources",
|
||||
"browse-smart-filters": "Browse by Smart Filters",
|
||||
"reading-list-restricted": "Reading list does not exist or you don't have access",
|
||||
"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 UpdateDashboardStream(int userId, DashboardStreamDto dto);
|
||||
Task UpdateDashboardStreamPosition(int userId, UpdateStreamPositionDto dto);
|
||||
Task UpdateSideNavStreamBulk(int userId, BulkUpdateSideNavStreamVisibilityDto dto);
|
||||
Task<SideNavStreamDto> CreateSideNavStreamFromSmartFilter(int userId, int smartFilterId);
|
||||
Task<SideNavStreamDto> CreateSideNavStreamFromExternalSource(int userId, int externalSourceId);
|
||||
Task UpdateSideNavStream(int userId, SideNavStreamDto dto);
|
||||
@ -31,6 +32,7 @@ public interface IStreamService
|
||||
Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto);
|
||||
Task<ExternalSourceDto> UpdateExternalSource(int userId, ExternalSourceDto dto);
|
||||
Task DeleteExternalSource(int userId, int externalSourceId);
|
||||
|
||||
}
|
||||
|
||||
public class StreamService : IStreamService
|
||||
@ -134,6 +136,20 @@ public class StreamService : IStreamService
|
||||
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)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.SideNavStreams);
|
||||
|
@ -252,6 +252,7 @@ public class Startup
|
||||
|
||||
// v0.7.9
|
||||
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
||||
await MigrateDashboardStreamNamesToLocaleKeys.Migrate(unitOfWork, dataContext, logger);
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
@ -269,9 +270,9 @@ public class Startup
|
||||
logger.LogCritical(ex, "An error occurred during migration");
|
||||
}
|
||||
|
||||
|
||||
|
||||
app.UseMiddleware<ExceptionMiddleware>();
|
||||
app.UseMiddleware<SecurityEventMiddleware>();
|
||||
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
|
@ -14,7 +14,7 @@
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" />
|
||||
<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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</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,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.2.7",
|
||||
"@angular/cdk": "^16.2.6",
|
||||
"@angular/common": "^16.2.7",
|
||||
"@angular/compiler": "^16.2.7",
|
||||
"@angular/core": "^16.2.7",
|
||||
"@angular/forms": "^16.2.7",
|
||||
"@angular/localize": "^16.2.7",
|
||||
"@angular/platform-browser": "^16.2.7",
|
||||
"@angular/platform-browser-dynamic": "^16.2.7",
|
||||
"@angular/router": "^16.2.7",
|
||||
"@angular/animations": "^16.2.9",
|
||||
"@angular/cdk": "^16.2.8",
|
||||
"@angular/common": "^16.2.9",
|
||||
"@angular/compiler": "^16.2.9",
|
||||
"@angular/core": "^16.2.9",
|
||||
"@angular/forms": "^16.2.9",
|
||||
"@angular/localize": "^16.2.9",
|
||||
"@angular/platform-browser": "^16.2.9",
|
||||
"@angular/platform-browser-dynamic": "^16.2.9",
|
||||
"@angular/router": "^16.2.9",
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||
"@iplab/ngx-file-upload": "^16.0.2",
|
||||
"@microsoft/signalr": "^7.0.11",
|
||||
"@microsoft/signalr": "^7.0.12",
|
||||
"@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-persist-lang": "^5.0.0",
|
||||
"@ngneat/transloco-persist-translations": "^5.0.0",
|
||||
@ -41,10 +41,11 @@
|
||||
"eventsource": "^2.0.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"lazysizes": "^5.3.2",
|
||||
"luxon": "^3.4.3",
|
||||
"ng-circle-progress": "^1.7.1",
|
||||
"ng-select2-component": "^13.0.9",
|
||||
"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-slider-v2": "^16.0.2",
|
||||
"ngx-stars": "^1.6.5",
|
||||
@ -52,27 +53,28 @@
|
||||
"rxjs": "^7.8.0",
|
||||
"screenfull": "^6.0.2",
|
||||
"swiper": "^8.4.6",
|
||||
"tslib": "^2.6.1",
|
||||
"tslib": "^2.6.2",
|
||||
"zone.js": "^0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^16.2.4",
|
||||
"@angular-eslint/builder": "^16.1.0",
|
||||
"@angular-eslint/eslint-plugin": "^16.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^16.1.0",
|
||||
"@angular-eslint/schematics": "^16.1.0",
|
||||
"@angular-eslint/template-parser": "^16.1.0",
|
||||
"@angular/cli": "^16.2.4",
|
||||
"@angular/compiler-cli": "^16.2.7",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/node": "^20.4.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||
"@typescript-eslint/parser": "^6.3.0",
|
||||
"eslint": "^8.46.0",
|
||||
"@angular-devkit/build-angular": "^16.2.6",
|
||||
"@angular-eslint/builder": "^16.2.0",
|
||||
"@angular-eslint/eslint-plugin": "^16.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^16.2.0",
|
||||
"@angular-eslint/schematics": "^16.2.0",
|
||||
"@angular-eslint/template-parser": "^16.2.0",
|
||||
"@angular/cli": "^16.2.6",
|
||||
"@angular/compiler-cli": "^16.2.9",
|
||||
"@types/d3": "^7.4.1",
|
||||
"@types/luxon": "^3.3.2",
|
||||
"@types/node": "^20.8.6",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
"@typescript-eslint/parser": "^6.7.5",
|
||||
"eslint": "^8.51.0",
|
||||
"jsonminify": "^0.4.2",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"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
|
||||
}
|
||||
|
||||
export const allFields = Object.keys(FilterField)
|
||||
|
||||
const enumArray = Object.keys(FilterField)
|
||||
.filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0)
|
||||
.map(key => parseInt(key, 10))
|
||||
.sort((a, b) => a - b) as FilterField[];
|
||||
.map(key => {
|
||||
// @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"));
|
||||
}
|
||||
|
||||
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(
|
||||
map((response: User) => {
|
||||
const user = response;
|
||||
|
@ -9,6 +9,7 @@ import { Series } from '../_models/series';
|
||||
import { Volume } from '../_models/volume';
|
||||
import { AccountService } from './account.service';
|
||||
import { DeviceService } from './device.service';
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
|
||||
export enum Action {
|
||||
Submenu = -1,
|
||||
@ -93,7 +94,9 @@ export enum Action {
|
||||
*/
|
||||
RemoveFromOnDeck = 19,
|
||||
AddRuleGroup = 20,
|
||||
RemoveRuleGroup = 21
|
||||
RemoveRuleGroup = 21,
|
||||
MarkAsVisible = 22,
|
||||
MarkAsInvisible = 23,
|
||||
}
|
||||
|
||||
export interface ActionItem<T> {
|
||||
@ -135,6 +138,8 @@ export class ActionFactoryService {
|
||||
|
||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
||||
|
||||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
|
||||
isAdmin = false;
|
||||
hasDownloadRole = false;
|
||||
|
||||
@ -160,6 +165,10 @@ export class ActionFactoryService {
|
||||
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) {
|
||||
return this.applyCallbackToList(this.volumeActions, callback);
|
||||
}
|
||||
@ -564,6 +573,23 @@ export class ActionFactoryService {
|
||||
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) {
|
||||
|
@ -62,6 +62,10 @@ export class NavService {
|
||||
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.
|
||||
*/
|
||||
|
@ -44,14 +44,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngIf="events.length === 0">
|
||||
<td colspan="6">{{t('no-data')}}/td>
|
||||
<td colspan="6">{{t('no-data')}}</td>
|
||||
</tr>
|
||||
<tr *ngFor="let item of events; let idx = index;">
|
||||
<td>
|
||||
{{item.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium', } | defaultValue }}
|
||||
{{item.createdUtc | utcToLocalTime | defaultValue}}
|
||||
</td>
|
||||
<td>
|
||||
{{item.lastModifiedUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}
|
||||
{{item.lastModifiedUtc | utcToLocalTime | defaultValue }}
|
||||
</td>
|
||||
<td>
|
||||
{{item.scrobbleEventType | scrobbleEventType}}
|
||||
|
@ -14,11 +14,12 @@ import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-scrobble-history',
|
||||
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',
|
||||
styleUrls: ['./user-scrobble-history.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -6,7 +6,7 @@
|
||||
<div class="modal-body">
|
||||
|
||||
<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">
|
||||
<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)"
|
||||
|
@ -38,7 +38,7 @@
|
||||
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{item.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}
|
||||
{{item.createdUtc | utcToLocalTime | defaultValue }}
|
||||
</td>
|
||||
<td>
|
||||
{{item.comment}}
|
||||
|
@ -29,11 +29,12 @@ import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {DefaultDatePipe} from "../../pipe/default-date.pipe";
|
||||
import {DefaultValuePipe} from "../../pipe/default-value.pipe";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-scrobble-errors',
|
||||
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',
|
||||
styleUrls: ['./manage-scrobble-errors.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -58,7 +58,7 @@
|
||||
<td>
|
||||
{{task.title | titlecase}}
|
||||
</td>
|
||||
<td>{{task.lastExecutionUtc | translocoDate: {dateStyle: 'short', timeStyle: 'medium' } | defaultValue }}</td>
|
||||
<td>{{task.lastExecutionUtc | utcToLocalTime | defaultValue }}</td>
|
||||
<td>{{task.cron}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
@ -14,6 +14,7 @@ import {DefaultValuePipe} from '../../pipe/default-value.pipe';
|
||||
import {AsyncPipe, DatePipe, NgFor, NgIf, NgTemplateOutlet, TitleCasePipe} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@ngneat/transloco";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||
|
||||
interface AdhocTask {
|
||||
name: string;
|
||||
@ -29,7 +30,7 @@ interface AdhocTask {
|
||||
styleUrls: ['./manage-tasks-settings.component.scss'],
|
||||
standalone: true,
|
||||
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 {
|
||||
|
||||
|
@ -430,10 +430,10 @@
|
||||
<div>
|
||||
<div class="row g-0">
|
||||
<div class="col">
|
||||
{{t('added-title')}} {{volume.createdUtc | translocoDate: {dateStyle: 'short' } | defaultDate}}
|
||||
{{t('added-title')}} {{volume.createdUtc | utcToLocalTime | defaultDate}}
|
||||
</div>
|
||||
<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 class="row g-0">
|
||||
|
@ -53,7 +53,7 @@ import {ImageComponent} from "../../../shared/image/image.component";
|
||||
import {DefaultValuePipe} from "../../../pipe/default-value.pipe";
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {TranslocoDatePipe} from "@ngneat/transloco-locale";
|
||||
import {Volume} from "../../../_models/volume";
|
||||
import {UtcToLocalTimePipe} from "../../../pipe/utc-to-local-time.pipe";
|
||||
|
||||
enum TabID {
|
||||
General = 0,
|
||||
@ -68,32 +68,32 @@ enum TabID {
|
||||
@Component({
|
||||
selector: 'app-edit-series-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
CommonModule,
|
||||
TypeaheadComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesRelationComponent,
|
||||
SentenceCasePipe,
|
||||
MangaFormatPipe,
|
||||
DefaultDatePipe,
|
||||
TimeAgoPipe,
|
||||
TagBadgeComponent,
|
||||
PublicationStatusPipe,
|
||||
NgbTooltip,
|
||||
BytesPipe,
|
||||
ImageComponent,
|
||||
NgbCollapse,
|
||||
NgbNavOutlet,
|
||||
DefaultValuePipe,
|
||||
TranslocoModule,
|
||||
TranslocoDatePipe,
|
||||
|
||||
],
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
NgbNav,
|
||||
NgbNavContent,
|
||||
NgbNavItem,
|
||||
NgbNavLink,
|
||||
CommonModule,
|
||||
TypeaheadComponent,
|
||||
CoverImageChooserComponent,
|
||||
EditSeriesRelationComponent,
|
||||
SentenceCasePipe,
|
||||
MangaFormatPipe,
|
||||
DefaultDatePipe,
|
||||
TimeAgoPipe,
|
||||
TagBadgeComponent,
|
||||
PublicationStatusPipe,
|
||||
NgbTooltip,
|
||||
BytesPipe,
|
||||
ImageComponent,
|
||||
NgbCollapse,
|
||||
NgbNavOutlet,
|
||||
DefaultValuePipe,
|
||||
TranslocoModule,
|
||||
TranslocoDatePipe,
|
||||
UtcToLocalTimePipe,
|
||||
],
|
||||
templateUrl: './edit-series-modal.component.html',
|
||||
styleUrls: ['./edit-series-modal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
@ -1,6 +1,6 @@
|
||||
<ng-container *transloco="let t; read: 'bulk-operations'">
|
||||
<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">
|
||||
|
||||
<span class="highlight">
|
||||
|
@ -32,19 +32,23 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
|
||||
export class BulkOperationsComponent implements OnInit {
|
||||
|
||||
@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;
|
||||
hasMarkAsUnread: boolean = false;
|
||||
actions: Array<ActionItem<any>> = [];
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
|
||||
get Action() {
|
||||
return Action;
|
||||
}
|
||||
protected readonly Action = Action;
|
||||
|
||||
constructor(public bulkSelectionService: BulkSelectionService, private readonly cdRef: ChangeDetectorRef,
|
||||
private actionFactoryService: ActionFactoryService) { }
|
||||
|
||||
constructor() { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.bulkSelectionService.actions$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(actions => {
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { NavigationStart, Router } from '@angular/router';
|
||||
import { ReplaySubject } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {NavigationStart, Router} from '@angular/router';
|
||||
import {ReplaySubject} from 'rxjs';
|
||||
import {filter} from 'rxjs/operators';
|
||||
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.
|
||||
* 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({
|
||||
providedIn: 'root'
|
||||
@ -151,6 +151,10 @@ export class BulkSelectionService {
|
||||
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);
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<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')">
|
||||
{{chapter.createdUtc | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
|
||||
{{chapter.createdUtc | utcToLocalTime | translocoDate: {dateStyle: 'short', timeStyle: 'short' } | defaultDate}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@ -28,11 +28,12 @@ import {MetadataDetailComponent} from "../../series-detail/_components/metadata-
|
||||
import {TranslocoModule} from "@ngneat/transloco";
|
||||
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
|
||||
import {FilterField} from "../../_models/metadata/v2/filter-field";
|
||||
import {UtcToLocalTimePipe} from "../../pipe/utc-to-local-time.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-info-cards',
|
||||
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',
|
||||
styleUrls: ['./entity-info-cards.component.scss'],
|
||||
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 *ngIf="items.length > 100; else dragList">
|
||||
<ng-container *ngIf="items.length > virtualizeAfter; else dragList">
|
||||
<div class="example-list list-group-flush">
|
||||
<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="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>
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
|
||||
<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>
|
||||
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</virtual-scroller>
|
||||
@ -26,29 +18,47 @@
|
||||
</ng-container>
|
||||
<ng-template #dragList>
|
||||
<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="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>
|
||||
|
||||
<button class="btn btn-icon float-end" (click)="removeItem(item, i)" *ngIf="showRemoveButton" [disabled]="disabled">
|
||||
<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>
|
||||
<ng-container [ngTemplateOutlet]="removeBtn" [ngTemplateOutletContext]="{$implicit: item, idx: i}"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
{{t('instructions-alt')}}
|
||||
|
@ -61,6 +61,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accessibility-padding {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.bulk-padding {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
|
||||
.virtual-scroller, virtual-scroller {
|
||||
@ -71,3 +77,7 @@
|
||||
virtual-scroller.empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.manual-input {
|
||||
width: 83px;
|
||||
}
|
||||
|
@ -1,8 +1,22 @@
|
||||
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 { NgIf, NgFor, NgTemplateOutlet } from '@angular/common';
|
||||
import {NgIf, NgFor, NgTemplateOutlet, NgClass} from '@angular/common';
|
||||
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 {
|
||||
fromPosition: number;
|
||||
@ -22,10 +36,14 @@ export interface ItemRemoveEvent {
|
||||
styleUrls: ['./draggable-ordered-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
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 {
|
||||
|
||||
/**
|
||||
* After this many elements, drag and drop is disabled and we use a virtualized list instead
|
||||
*/
|
||||
@Input() virtualizeAfter = 100;
|
||||
@Input() accessibilityMode: boolean = false;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@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}`;
|
||||
@Output() orderUpdated: EventEmitter<IndexUpdateEvent> = new EventEmitter<IndexUpdateEvent>();
|
||||
@Output() itemRemove: EventEmitter<ItemRemoveEvent> = new EventEmitter<ItemRemoveEvent>();
|
||||
@ContentChild('draggableItem') itemTemplate!: TemplateRef<any>;
|
||||
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
|
||||
get BufferAmount() {
|
||||
return Math.min(this.items.length / 20, 20);
|
||||
}
|
||||
@ -85,4 +109,11 @@ export class DraggableOrderedListComponent {
|
||||
});
|
||||
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>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
|
||||
</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;">
|
||||
<li [ngbNavItem]="TabID.Dashboard">
|
||||
<a ngbNavLink>{{t(TabID.Dashboard)}}</a>
|
||||
|
@ -49,6 +49,12 @@ export class CustomizeDashboardStreamsComponent {
|
||||
constructor(public modal: NgbActiveModal) {
|
||||
forkJoin([this.dashboardService.getDashboardStreams(false), this.filterService.getAllFilters()]).subscribe(results => {
|
||||
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));
|
||||
this.smartFilters = results[1].filter(d => !smartFilterStreams.has(d.name));
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -10,14 +10,25 @@
|
||||
<span role="alert" class="mt-1" *ngIf="listForm.get('filterSideNavStream')?.value">{{t('reorder-when-filter-present')}}</span>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="form-check form-check-inline" style="margin-top: 35px; margin-left: 10px">
|
||||
<input class="form-check-input" type="checkbox" id="accessibility-mode" [value]="accessibilityMode" (change)="updateAccessibilityMode()">
|
||||
<label class="form-check-label" for="accessibility-mode">{{t('order-numbers-label')}}</label>
|
||||
</div>
|
||||
<form [formGroup]="pageOperationsForm">
|
||||
<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>
|
||||
</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>
|
||||
<app-draggable-ordered-list [items]="items | filter: filterSideNavStreams" (orderUpdated)="orderUpdated($event)" [accessibilityMode]="accessibilityMode"
|
||||
[showRemoveButton]="false" [disabled]="listForm.get('filterSideNavStream')?.value">
|
||||
<app-bulk-operations [modalMode]="true" [topOffset]="0" [actionCallback]="bulkActionCallback"></app-bulk-operations>
|
||||
<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>
|
||||
<app-sidenav-stream-list-item [item]="item" [position]="position" (hide)="updateVisibility($event, position)"></app-sidenav-stream-list-item>
|
||||
</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 {SmartFilter} from "../../../_models/metadata/v2/smart-filter";
|
||||
import {FilterService} from "../../../_services/filter.service";
|
||||
@ -11,36 +11,43 @@ import {
|
||||
import {SideNavStream} from "../../../_models/sidenav/sidenav-stream";
|
||||
import {NavService} from "../../../_services/nav.service";
|
||||
import {DashboardStreamListItemComponent} from "../dashboard-stream-list-item/dashboard-stream-list-item.component";
|
||||
import {CommonStream} from "../../../_models/common-stream";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {SidenavStreamListItemComponent} from "../sidenav-stream-list-item/sidenav-stream-list-item.component";
|
||||
import {ExternalSourceService} from "../../../external-source.service";
|
||||
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 {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
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({
|
||||
selector: 'app-customize-sidenav-streams',
|
||||
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',
|
||||
styleUrls: ['./customize-sidenav-streams.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CustomizeSidenavStreamsComponent {
|
||||
export class CustomizeSidenavStreamsComponent implements OnDestroy {
|
||||
|
||||
//@Input({required: true}) parentScrollElem!: Element | Window;
|
||||
items: SideNavStream[] = [];
|
||||
smartFilters: SmartFilter[] = [];
|
||||
externalSources: ExternalSource[] = [];
|
||||
accessibilityMode: boolean = false;
|
||||
|
||||
listForm: FormGroup = new FormGroup({
|
||||
'filterSideNavStream': new FormControl('', []),
|
||||
'filterSmartFilter': new FormControl('', []),
|
||||
'filterExternalSource': new FormControl('', []),
|
||||
});
|
||||
pageOperationsForm: FormGroup = new FormGroup({
|
||||
'accessibilityMode': new FormControl(false, []),
|
||||
'bulkMode': new FormControl(false, [])
|
||||
})
|
||||
|
||||
filterSideNavStreams = (listItem: SideNavStream) => {
|
||||
const filterVal = (this.listForm.value.filterSideNavStream || '').toLowerCase();
|
||||
@ -57,17 +64,87 @@ export class CustomizeSidenavStreamsComponent {
|
||||
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 filterService = inject(FilterService);
|
||||
private readonly externalSourceService = inject(ExternalSourceService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
|
||||
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),
|
||||
this.filterService.getAllFilters(), this.externalSourceService.getExternalSources()
|
||||
]).subscribe(results => {
|
||||
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));
|
||||
this.smartFilters = results[1].filter(d => !existingSmartFilterStreams.has(d.name));
|
||||
|
||||
@ -77,6 +154,10 @@ export class CustomizeSidenavStreamsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.bulkSelectionService.deselectAll();
|
||||
}
|
||||
|
||||
resetSideNavFilter() {
|
||||
this.listForm.get('filterSideNavStream')?.setValue('');
|
||||
this.cdRef.markForCheck();
|
||||
@ -108,11 +189,6 @@ export class CustomizeSidenavStreamsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
updateAccessibilityMode() {
|
||||
this.accessibilityMode = !this.accessibilityMode;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
orderUpdated(event: IndexUpdateEvent) {
|
||||
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) {
|
||||
const stream = this.items.filter(s => s.id == item.id)[0];
|
||||
stream.visible = !stream.visible;
|
||||
this.sideNavService.updateSideNavStream(stream).subscribe();
|
||||
this.cdRef.markForCheck();
|
||||
this.sideNavService.updateSideNavStream(stream).subscribe();
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -24,5 +24,10 @@
|
||||
(sourceDelete)="deleteSource(idx, $event)"
|
||||
[isViewMode]="externalSource.id !== 0"></app-edit-external-source-item>
|
||||
</ng-container>
|
||||
<ul>
|
||||
<li class="list-group-item" *ngIf="externalSources.length === 0">
|
||||
{{t('no-data')}}
|
||||
</li>
|
||||
</ul>
|
||||
</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;
|
||||
margin: 5px 0;
|
||||
color: var(--list-group-hover-text-color);
|
||||
background-color: var(--list-group-hover-bg-color);
|
||||
background-color: var(--card-bg-color);
|
||||
|
||||
span {
|
||||
cursor: pointer;
|
||||
|
@ -159,11 +159,11 @@ export class SideNavComponent implements OnInit {
|
||||
}
|
||||
|
||||
handleHomeActions() {
|
||||
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl'});
|
||||
this.ngbModal.open(CustomizeDashboardModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||
}
|
||||
|
||||
importCbl() {
|
||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl'});
|
||||
this.ngbModal.open(ImportCblModalComponent, {size: 'xl', fullscreen: 'md'});
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Library>, library: Library) {
|
||||
|
@ -2,7 +2,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
DestroyRef, importProvidersFrom,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
@ -49,6 +49,11 @@ import { SideNavCompanionBarComponent } from '../../sidenav/_components/side-nav
|
||||
import {LocalizationService} from "../../_services/localization.service";
|
||||
import {Language} from "../../_models/metadata/language";
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {
|
||||
provideTranslocoPersistTranslations,
|
||||
TranslocoPersistTranslations
|
||||
} from "@ngneat/transloco-persist-translations";
|
||||
import {HttpLoader} from "../../../httpLoader";
|
||||
|
||||
enum AccordionPanelID {
|
||||
ImageReader = 'image-reader',
|
||||
@ -76,7 +81,7 @@ enum FragmentID {
|
||||
ChangePasswordComponent, ChangeAgeRestrictionComponent, AnilistKeyComponent, ReactiveFormsModule, NgbAccordionDirective, NgbAccordionItem, NgbAccordionHeader,
|
||||
NgbAccordionToggle, NgbAccordionButton, NgbCollapse, NgbAccordionCollapse, NgbAccordionBody, NgbTooltip, NgTemplateOutlet, ColorPickerModule, ApiKeyComponent,
|
||||
ThemeManagerComponent, ManageDevicesComponent, UserStatsComponent, UserScrobbleHistoryComponent, UserHoldsComponent, NgbNavOutlet, TitleCasePipe, SentenceCasePipe,
|
||||
TranslocoDirective]
|
||||
TranslocoDirective],
|
||||
})
|
||||
export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
|
||||
@ -114,20 +119,22 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
opdsEnabled: boolean = false;
|
||||
opdsUrl: string = '';
|
||||
makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl; };
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
get AccordionPanelID() {
|
||||
return AccordionPanelID;
|
||||
}
|
||||
|
||||
get FragmentID() {
|
||||
return FragmentID;
|
||||
}
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly bookService = inject(BookService);
|
||||
private readonly titleService = inject(Title);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
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,
|
||||
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
|
||||
private router: Router, private readonly cdRef: ChangeDetectorRef, public localizationService: LocalizationService) {
|
||||
constructor() {
|
||||
this.fontFamilies = this.bookService.getFontFamilies().map(f => f.title);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
@ -306,6 +313,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
|
||||
this.toastr.success(translate('user-preferences.success-toast'));
|
||||
if (this.user) {
|
||||
this.user.preferences = updatedPrefs;
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
this.resetForm();
|
||||
|
@ -1315,6 +1315,7 @@
|
||||
"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.",
|
||||
"reorder-label": "Reorder",
|
||||
"bulk-select-label": "Bulk Select item",
|
||||
"remove-item-alt": "Remove item"
|
||||
},
|
||||
|
||||
@ -1771,15 +1772,17 @@
|
||||
"smart-filters-title": "Smart Filters",
|
||||
"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}}",
|
||||
"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": {
|
||||
"add-source": "Add",
|
||||
"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}}",
|
||||
"filter": "{{common.filter}}"
|
||||
"filter": "{{common.filter}}",
|
||||
"no-data": "No External Sources exist"
|
||||
},
|
||||
|
||||
"manage-smart-filters": {
|
||||
@ -1978,7 +1981,9 @@
|
||||
"add-rule-group-and": "Add Rule Group (AND)",
|
||||
"add-rule-group-or": "Add Rule Group (OR)",
|
||||
"remove-rule-group": "Remove Rule Group",
|
||||
"customize": "Customize"
|
||||
"customize": "Customize",
|
||||
"mark-visible": "Mark as Visible",
|
||||
"mark-invisible": "Mark as Invisible"
|
||||
},
|
||||
|
||||
"preferences": {
|
||||
|
@ -15,8 +15,8 @@ import { JwtInterceptor } from './app/_interceptors/jwt.interceptor';
|
||||
import { ErrorInterceptor } from './app/_interceptors/error.interceptor';
|
||||
import {HTTP_INTERCEPTORS, withInterceptorsFromDi, provideHttpClient} from '@angular/common/http';
|
||||
import {
|
||||
provideTransloco,
|
||||
TranslocoService
|
||||
provideTransloco, TranslocoConfig,
|
||||
TranslocoService
|
||||
} from "@ngneat/transloco";
|
||||
import {environment} from "./environments/environment";
|
||||
import {HttpLoader} from "./httpLoader";
|
||||
@ -96,7 +96,7 @@ const languageCodes = [
|
||||
'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',
|
||||
'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 = {
|
||||
@ -109,8 +109,8 @@ const translocoOptions = {
|
||||
missingHandler: {
|
||||
useFallbackTranslation: true,
|
||||
allowEmpty: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
} as TranslocoConfig
|
||||
};
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
@ -133,7 +133,8 @@ bootstrapApplication(AppComponent, {
|
||||
}),
|
||||
provideTranslocoPersistTranslations({
|
||||
loader: HttpLoader,
|
||||
storage: { useValue: localStorage }
|
||||
storage: { useValue: localStorage },
|
||||
ttl: 604800
|
||||
}),
|
||||
provideTranslocoPersistLang({
|
||||
storage: {
|
||||
|
74
openapi.json
74
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.8.8"
|
||||
"version": "0.7.8.10"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -285,6 +285,7 @@
|
||||
"Account"
|
||||
],
|
||||
"summary": "Resets the API Key assigned with a user",
|
||||
"description": "This will log unauthorized requests to Security log",
|
||||
"responses": {
|
||||
"200": {
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -4202,6 +4225,7 @@
|
||||
"Plugin"
|
||||
],
|
||||
"summary": "Returns the version of the Kavita install",
|
||||
"description": "This will log unauthorized requests to Security log",
|
||||
"parameters": [
|
||||
{
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@ -13108,6 +13163,23 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BulkUpdateSideNavStreamVisibilityDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"visibility": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"CblBookResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
Loading…
x
Reference in New Issue
Block a user