Random Stuff (#3798)

This commit is contained in:
Joe Milazzo 2025-05-10 15:57:14 -06:00 committed by GitHub
parent 574cf4b78e
commit 70f00895e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 659 additions and 567 deletions

View File

@ -9,10 +9,10 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" /> <PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" /> <PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>

View File

@ -51,7 +51,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.12.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -66,7 +66,7 @@
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" /> <PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" /> <PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" /> <PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.0" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" /> <PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.18" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
@ -78,7 +78,7 @@
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<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="3.0.0" /> <PackageReference Include="NetVips" Version="3.0.1" />
<PackageReference Include="NetVips.Native" Version="8.16.1" /> <PackageReference Include="NetVips.Native" Version="8.16.1" />
<PackageReference Include="Serilog" Version="4.2.0" /> <PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
@ -87,20 +87,20 @@
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" /> <PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.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.39.0" /> <PackageReference Include="SharpCompress" Version="0.39.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526"> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
<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="8.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" /> <PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" /> <PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
<PackageReference Include="System.Drawing.Common" Version="9.0.4" /> <PackageReference Include="System.Drawing.Common" Version="9.0.4" />
<PackageReference Include="VersOne.Epub" Version="3.3.3" /> <PackageReference Include="VersOne.Epub" Version="3.3.4" />
<PackageReference Include="YamlDotNet" Version="16.3.0" /> <PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>
@ -111,17 +111,16 @@
<ItemGroup> <ItemGroup>
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" /> <None Remove="obj\**" />
<None Remove="cache\**" /> <None Remove="cache\**" />
<None Remove="cache-long\**" />
<None Remove="backups\**" /> <None Remove="backups\**" />
<None Remove="logs\**" /> <None Remove="logs\**" />
<None Remove="temp\**" /> <None Remove="temp\**" />
<None Remove="kavita.log" /> <None Remove="config\kavita.log" />
<None Remove="kavita.db" /> <None Remove="config\kavita.db" />
<None Remove="covers\**" /> <None Remove="config\covers\**" />
<None Remove="wwwroot\**" /> <None Remove="wwwroot\**" />
<None Remove="cache\cache-long\**" />
<None Remove="config\cache\**" /> <None Remove="config\cache\**" />
<None Remove="config\logs\**" /> <None Remove="config\logs\**" />
<None Remove="config\covers\**" /> <None Remove="config\covers\**" />
@ -139,6 +138,7 @@
<Compile Remove="covers\**" /> <Compile Remove="covers\**" />
<Compile Remove="wwwroot\**" /> <Compile Remove="wwwroot\**" />
<Compile Remove="config\cache\**" /> <Compile Remove="config\cache\**" />
<Compile Remove="cache\cache-long\**" />
<Compile Remove="config\logs\**" /> <Compile Remove="config\logs\**" />
<Compile Remove="config\covers\**" /> <Compile Remove="config\covers\**" />
<Compile Remove="config\bookmarks\**" /> <Compile Remove="config\bookmarks\**" />
@ -188,7 +188,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="config\cache-long\" />
<Folder Include="config\themes" /> <Folder Include="config\themes" />
<Content Include="EmailTemplates\**"> <Content Include="EmailTemplates\**">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@ -4,14 +4,18 @@ using API.DTOs.Scrobbling;
namespace API.DTOs.KavitaPlus.ExternalMetadata; namespace API.DTOs.KavitaPlus.ExternalMetadata;
#nullable enable #nullable enable
/// <summary>
/// Represents a request to match some series from Kavita to an external id which K+ uses.
/// </summary>
internal sealed record MatchSeriesRequestDto internal sealed record MatchSeriesRequestDto
{ {
public string SeriesName { get; set; } public required string SeriesName { get; set; }
public ICollection<string> AlternativeNames { get; set; } public ICollection<string> AlternativeNames { get; set; } = [];
public int Year { get; set; } = 0; public int Year { get; set; } = 0;
public string Query { get; set; } public string? Query { get; set; }
public int? AniListId { get; set; } public int? AniListId { get; set; }
public long? MalId { get; set; } public long? MalId { get; set; }
public string? HardcoverId { get; set; } public string? HardcoverId { get; set; }
public int? CbrId { get; set; }
public PlusMediaFormat Format { get; set; } public PlusMediaFormat Format { get; set; }
} }

View File

@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Net; using System.Net;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next)
} }
catch (KavitaUnauthenticatedUserException ex) catch (KavitaUnauthenticatedUserException ex)
{ {
var ipAddress = context.Connection.RemoteIpAddress?.ToString(); var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString();
var requestMethod = context.Request.Method; var requestMethod = context.Request.Method;
var requestPath = context.Request.Path; var requestPath = context.Request.Path;
var userAgent = context.Request.Headers.UserAgent; var userAgent = context.Request.Headers.UserAgent;

View File

@ -1,4 +1,5 @@
using System; using System;
using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using System.Security.Cryptography; using System.Security.Cryptography;
@ -48,15 +49,13 @@ public class Program
var directoryService = new DirectoryService(null!, new FileSystem()); var directoryService = new DirectoryService(null!, new FileSystem());
// Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json
HandleFirstRunConfiguration();
// Before anything, check if JWT has been generated properly or if user still has default // Before anything, check if JWT has been generated properly or if user still has default
if (!Configuration.CheckIfJwtTokenSet() && EnsureJwtTokenKey();
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development)
{
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
var rBytes = new byte[256];
RandomNumberGenerator.Create().GetBytes(rBytes);
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
}
try try
{ {
@ -70,6 +69,7 @@ public class Program
{ {
var logger = services.GetRequiredService<ILogger<Program>>(); var logger = services.GetRequiredService<ILogger<Program>>();
var context = services.GetRequiredService<DataContext>(); var context = services.GetRequiredService<DataContext>();
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
var isDbCreated = await context.Database.CanConnectAsync(); var isDbCreated = await context.Database.CanConnectAsync();
if (isDbCreated && pendingMigrations.Any()) if (isDbCreated && pendingMigrations.Any())
@ -157,6 +157,26 @@ public class Program
} }
} }
private static void EnsureJwtTokenKey()
{
if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return;
Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions...");
var rBytes = new byte[256];
RandomNumberGenerator.Create().GetBytes(rBytes);
Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty);
}
private static void HandleFirstRunConfiguration()
{
var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json");
if (File.Exists(firstRunConfigFilePath) &&
!File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")))
{
File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"));
}
}
private static async Task<string> GetMigrationDirectory(DataContext context, IDirectoryService directoryService) private static async Task<string> GetMigrationDirectory(DataContext context, IDirectoryService directoryService)
{ {
string? currentVersion = null; string? currentVersion = null;

View File

@ -226,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
Year = series.Metadata.ReleaseYear, Year = series.Metadata.ReleaseYear,
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
MalId = potentialMalId ?? ScrobblingService.GetMalId(series), MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
}; };
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
@ -792,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService
var characters = externalCharacters var characters = externalCharacters
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w.Name, Name = w.Name.Trim(),
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
@ -873,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService
var artists = upstreamArtists var artists = upstreamArtists
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w.Name, Name = w.Name.Trim(),
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
@ -929,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService
var writers = upstreamWriters var writers = upstreamWriters
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w.Name, Name = w.Name.Trim(),
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite), AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
}) })
@ -1353,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService
var people = staff! var people = staff!
.Select(w => new PersonDto() .Select(w => new PersonDto()
{ {
Name = w, Name = w.Trim(),
}) })
.Concat(chapter.People .Concat(chapter.People
.Where(p => p.Role == role) .Where(p => p.Role == role)

View File

@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService
else else
{ {
_directoryService.DeleteFiles([tempFullPath]); _directoryService.DeleteFiles([tempFullPath]);
person.CoverImage = Path.GetFileName(existingPath); return;
} }
} }
else else
@ -651,6 +651,7 @@ public class CoverDbService : ICoverDbService
else else
{ {
_directoryService.DeleteFiles([tempFullPath]); _directoryService.DeleteFiles([tempFullPath]);
return;
} }
chapter.CoverImage = finalFileName; chapter.CoverImage = finalFileName;

View File

@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher
if (rootFolder.Count == 0) return string.Empty; if (rootFolder.Count == 0) return string.Empty;
// Select the first folder and join with library folder, this should give us the folder to scan. // Select the first folder and join with library folder, this should give us the folder to scan.
return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1]));
} }

View File

@ -52,6 +52,7 @@ public interface IVersionUpdaterService
Task PushUpdate(UpdateNotificationDto update); Task PushUpdate(UpdateNotificationDto update);
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0); Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false); Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
void BustGithubCache();
} }
@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
{ {
var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
return System.Text.Json.JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData); return JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
} }
return null; return null;
@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
{ {
try try
{ {
var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions); var json = JsonSerializer.Serialize(update, JsonOptions);
await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json);
} }
catch (Exception ex) catch (Exception ex)
@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService
.Count(u => u.IsReleaseNewer); .Count(u => u.IsReleaseNewer);
} }
/// <summary>
/// Clears the Github cache
/// </summary>
public void BustGithubCache()
{
try
{
File.Delete(_cacheFilePath);
File.Delete(_cacheLatestReleaseFilePath);
} catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear Github cache");
}
}
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update)
{ {
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null; if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;

View File

@ -55,6 +55,9 @@ public class Startup
{ {
_config = config; _config = config;
_env = env; _env = env;
// Disable Hangfire Automatic Retry
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 });
} }
// This method gets called by the runtime. Use this method to add services to the container. // This method gets called by the runtime. Use this method to add services to the container.
@ -223,7 +226,7 @@ public class Startup
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env,
IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService,
IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService)
{ {
var logger = serviceProvider.GetRequiredService<ILogger<Program>>(); var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
@ -235,9 +238,10 @@ public class Startup
// Apply all migrations on startup // Apply all migrations on startup
var dataContext = serviceProvider.GetRequiredService<DataContext>(); var dataContext = serviceProvider.GetRequiredService<DataContext>();
logger.LogInformation("Running Migrations"); logger.LogInformation("Running Migrations");
#region Migrations
// v0.7.9 // v0.7.9
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
@ -289,13 +293,23 @@ public class Startup
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
#endregion
// 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);
var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString();
installVersion.Value = BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString();
unitOfWork.SettingsRepository.Update(installVersion); unitOfWork.SettingsRepository.Update(installVersion);
await unitOfWork.CommitAsync(); await unitOfWork.CommitAsync();
logger.LogInformation("Running Migrations - complete"); logger.LogInformation("Running Migrations - complete");
if (isVersionDifferent)
{
// Clear the Github cache so update stuff shows correctly
versionService.BustGithubCache();
}
}).GetAwaiter() }).GetAwaiter()
.GetResult(); .GetResult();
} }

View File

@ -14,7 +14,7 @@
<PackageReference Include="Flurl.Http" Version="4.0.2" /> <PackageReference Include="Flurl.Http" Version="4.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526"> <PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
import {ReadingList} from '../_models/reading-list'; import {ReadingList} from '../_models/reading-list';
import {Series} from '../_models/series'; import {Series} from '../_models/series';
import {Volume} from '../_models/volume'; import {Volume} from '../_models/volume';
import {AccountService} from './account.service'; import {AccountService, Role} from './account.service';
import {DeviceService} from './device.service'; import {DeviceService} from './device.service';
import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SideNavStream} from "../_models/sidenav/sidenav-stream";
import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {translate} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {Person} from "../_models/metadata/person"; import {Person} from "../_models/metadata/person";
import {User} from '../_models/user';
export enum Action { export enum Action {
Submenu = -1, Submenu = -1,
@ -106,7 +107,7 @@ export enum Action {
Promote = 24, Promote = 24,
UnPromote = 25, UnPromote = 25,
/** /**
* Invoke a refresh covers as false to generate colorscapes * Invoke refresh covers as false to generate colorscapes
*/ */
GenerateColorScape = 26, GenerateColorScape = 26,
/** /**
@ -126,14 +127,21 @@ export enum Action {
/** /**
* Callback for an action * Callback for an action
*/ */
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void; export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean; export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
export interface ActionItem<T> { export interface ActionItem<T> {
title: string; title: string;
description: string; description: string;
action: Action; action: Action;
callback: ActionCallback<T>; callback: ActionCallback<T>;
/**
* Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply.
*/
requiredRoles: Role[];
/**
* @deprecated Use required Roles instead
*/
requiresAdmin: boolean; requiresAdmin: boolean;
children: Array<ActionItem<T>>; children: Array<ActionItem<T>>;
/** /**
@ -149,94 +157,98 @@ export interface ActionItem<T> {
* Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return
*/ */
_extra?: {title: string, data: any}; _extra?: {title: string, data: any};
/**
* Will call on each action to determine if it should show for the appropriate entity based on state and user
*/
shouldRender: ActionShouldRenderFunc<T>;
} }
/**
* Entities that can be actioned upon
*/
export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null;
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ActionFactoryService { export class ActionFactoryService {
libraryActions: Array<ActionItem<Library>> = []; private libraryActions: Array<ActionItem<Library>> = [];
private seriesActions: Array<ActionItem<Series>> = [];
seriesActions: Array<ActionItem<Series>> = []; private volumeActions: Array<ActionItem<Volume>> = [];
private chapterActions: Array<ActionItem<Chapter>> = [];
volumeActions: Array<ActionItem<Volume>> = []; private collectionTagActions: Array<ActionItem<UserCollection>> = [];
private readingListActions: Array<ActionItem<ReadingList>> = [];
chapterActions: Array<ActionItem<Chapter>> = []; private bookmarkActions: Array<ActionItem<Series>> = [];
collectionTagActions: Array<ActionItem<UserCollection>> = [];
readingListActions: Array<ActionItem<ReadingList>> = [];
bookmarkActions: Array<ActionItem<Series>> = [];
private personActions: Array<ActionItem<Person>> = []; private personActions: Array<ActionItem<Person>> = [];
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
sideNavStreamActions: Array<ActionItem<SideNavStream>> = []; private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
smartFilterActions: Array<ActionItem<SmartFilter>> = []; private sideNavHomeActions: Array<ActionItem<void>> = [];
sideNavHomeActions: Array<ActionItem<void>> = [];
isAdmin = false;
constructor(private accountService: AccountService, private deviceService: DeviceService) { constructor(private accountService: AccountService, private deviceService: DeviceService) {
this.accountService.currentUser$.subscribe((user) => { this.accountService.currentUser$.subscribe((_) => {
if (user) {
this.isAdmin = this.accountService.hasAdminRole(user);
} else {
this._resetActions();
return; // If user is logged out, we don't need to do anything
}
this._resetActions(); this._resetActions();
}); });
} }
getLibraryActions(callback: ActionCallback<Library>) { getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
return this.applyCallbackToList(this.libraryActions, callback); return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
} }
getSeriesActions(callback: ActionCallback<Series>) { getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
return this.applyCallbackToList(this.seriesActions, callback); return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
} }
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) { getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavStreamActions, callback); return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
} }
getSmartFilterActions(callback: ActionCallback<SmartFilter>) { getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
return this.applyCallbackToList(this.smartFilterActions, callback); return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
} }
getVolumeActions(callback: ActionCallback<Volume>) { getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
return this.applyCallbackToList(this.volumeActions, callback); return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
} }
getChapterActions(callback: ActionCallback<Chapter>) { getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
return this.applyCallbackToList(this.chapterActions, callback); return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
} }
getCollectionTagActions(callback: ActionCallback<UserCollection>) { getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
return this.applyCallbackToList(this.collectionTagActions, callback); return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
} }
getReadingListActions(callback: ActionCallback<ReadingList>) { getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
return this.applyCallbackToList(this.readingListActions, callback); return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
} }
getBookmarkActions(callback: ActionCallback<Series>) { getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
return this.applyCallbackToList(this.bookmarkActions, callback); return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
} }
getPersonActions(callback: ActionCallback<Person>) { getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
return this.applyCallbackToList(this.personActions, callback); return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
} }
getSideNavHomeActions(callback: ActionCallback<void>) { getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
return this.applyCallbackToList(this.sideNavHomeActions, callback); return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc);
} }
dummyCallback(action: ActionItem<any>, data: any) {} dummyCallback(action: ActionItem<any>, entity: any) {}
dummyShouldRender(action: ActionItem<any>, entity: any, user: User) {return true;}
basicReadRender(action: ActionItem<any>, entity: any, user: User) {
if (entity === null || entity === undefined) return true;
if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true;
switch (action.action) {
case(Action.MarkAsRead):
return entity.pagesRead < entity.pages;
case(Action.MarkAsUnread):
return entity.pagesRead !== 0;
default:
return true;
}
}
filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) { filterSendToAction(actions: Array<ActionItem<Chapter>>, chapter: Chapter) {
// if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) {
@ -279,7 +291,7 @@ export class ActionFactoryService {
return tasks.filter(t => !blacklist.includes(t.action)); return tasks.filter(t => !blacklist.includes(t.action));
} }
getBulkLibraryActions(callback: ActionCallback<Library>) { getBulkLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
// Scan is currently not supported due to the backend not being able to handle it yet // Scan is currently not supported due to the backend not being able to handle it yet
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => { const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
@ -293,11 +305,13 @@ export class ActionFactoryService {
dynamicList: undefined, dynamicList: undefined,
action: Action.CopySettings, action: Action.CopySettings,
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: shouldRenderFunc,
children: [], children: [],
requiredRoles: [Role.Admin],
requiresAdmin: true, requiresAdmin: true,
title: 'copy-settings' title: 'copy-settings'
}) })
return this.applyCallbackToList(actions, callback); return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem<Library>[];
} }
flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> { flattenActions<T>(actions: Array<ActionItem<T>>): Array<ActionItem<T>> {
@ -323,7 +337,9 @@ export class ActionFactoryService {
title: 'scan-library', title: 'scan-library',
description: 'scan-library-tooltip', description: 'scan-library-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -331,14 +347,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [ children: [
{ {
action: Action.RefreshMetadata, action: Action.RefreshMetadata,
title: 'refresh-covers', title: 'refresh-covers',
description: 'refresh-covers-tooltip', description: 'refresh-covers-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -346,7 +366,9 @@ export class ActionFactoryService {
title: 'generate-colorscape', title: 'generate-colorscape',
description: 'generate-colorscape-tooltip', description: 'generate-colorscape-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -354,7 +376,9 @@ export class ActionFactoryService {
title: 'analyze-files', title: 'analyze-files',
description: 'analyze-files-tooltip', description: 'analyze-files-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -362,7 +386,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
], ],
@ -372,7 +398,9 @@ export class ActionFactoryService {
title: 'settings', title: 'settings',
description: 'settings-tooltip', description: 'settings-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
]; ];
@ -383,7 +411,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -391,7 +421,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -400,7 +432,9 @@ export class ActionFactoryService {
title: 'promote', title: 'promote',
description: 'promote-tooltip', description: 'promote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -408,7 +442,9 @@ export class ActionFactoryService {
title: 'unpromote', title: 'unpromote',
description: 'unpromote-tooltip', description: 'unpromote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -419,7 +455,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -427,7 +465,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -435,7 +475,9 @@ export class ActionFactoryService {
title: 'scan-series', title: 'scan-series',
description: 'scan-series-tooltip', description: 'scan-series-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -443,14 +485,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToWantToReadList, action: Action.AddToWantToReadList,
title: 'add-to-want-to-read', title: 'add-to-want-to-read',
description: 'add-to-want-to-read-tooltip', description: 'add-to-want-to-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -458,7 +504,9 @@ export class ActionFactoryService {
title: 'remove-from-want-to-read', title: 'remove-from-want-to-read',
description: 'remove-to-want-to-read-tooltip', description: 'remove-to-want-to-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -466,7 +514,9 @@ export class ActionFactoryService {
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -474,26 +524,11 @@ export class ActionFactoryService {
title: 'add-to-collection', title: 'add-to-collection',
description: 'add-to-collection-tooltip', description: 'add-to-collection-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
// {
// action: Action.AddToScrobbleHold,
// title: 'add-to-scrobble-hold',
// description: 'add-to-scrobble-hold-tooltip',
// callback: this.dummyCallback,
// requiresAdmin: true,
// children: [],
// },
// {
// action: Action.RemoveFromScrobbleHold,
// title: 'remove-from-scrobble-hold',
// description: 'remove-from-scrobble-hold-tooltip',
// callback: this.dummyCallback,
// requiresAdmin: true,
// children: [],
// },
], ],
}, },
{ {
@ -501,14 +536,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -521,14 +560,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [],
children: [ children: [
{ {
action: Action.RefreshMetadata, action: Action.RefreshMetadata,
title: 'refresh-covers', title: 'refresh-covers',
description: 'refresh-covers-tooltip', description: 'refresh-covers-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -536,7 +579,9 @@ export class ActionFactoryService {
title: 'generate-colorscape', title: 'generate-colorscape',
description: 'generate-colorscape-tooltip', description: 'generate-colorscape-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -544,7 +589,9 @@ export class ActionFactoryService {
title: 'analyze-files', title: 'analyze-files',
description: 'analyze-files-tooltip', description: 'analyze-files-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -552,7 +599,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -563,7 +612,9 @@ export class ActionFactoryService {
title: 'match', title: 'match',
description: 'match-tooltip', description: 'match-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -571,7 +622,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [Role.Download],
children: [], children: [],
}, },
{ {
@ -579,7 +632,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
]; ];
@ -590,7 +645,9 @@ export class ActionFactoryService {
title: 'read-incognito', title: 'read-incognito',
description: 'read-incognito-tooltip', description: 'read-incognito-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -598,7 +655,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -606,7 +665,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -614,14 +675,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '=', description: '=',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToReadingList, action: Action.AddToReadingList,
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -631,14 +696,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -651,14 +720,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.Delete, action: Action.Delete,
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -666,7 +739,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
] ]
@ -676,7 +751,9 @@ export class ActionFactoryService {
title: 'details', title: 'details',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -687,7 +764,9 @@ export class ActionFactoryService {
title: 'read-incognito', title: 'read-incognito',
description: 'read-incognito-tooltip', description: 'read-incognito-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -695,7 +774,9 @@ export class ActionFactoryService {
title: 'mark-as-read', title: 'mark-as-read',
description: 'mark-as-read-tooltip', description: 'mark-as-read-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -703,7 +784,9 @@ export class ActionFactoryService {
title: 'mark-as-unread', title: 'mark-as-unread',
description: 'mark-as-unread-tooltip', description: 'mark-as-unread-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -711,14 +794,18 @@ export class ActionFactoryService {
title: 'add-to', title: 'add-to',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.AddToReadingList, action: Action.AddToReadingList,
title: 'add-to-reading-list', title: 'add-to-reading-list',
description: 'add-to-reading-list-tooltip', description: 'add-to-reading-list-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -728,14 +815,18 @@ export class ActionFactoryService {
title: 'send-to', title: 'send-to',
description: 'send-to-tooltip', description: 'send-to-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.SendTo, action: Action.SendTo,
title: '', title: '',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => { dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d}; return {'title': d.name, 'data': d};
}), shareReplay())), }), shareReplay())),
@ -749,14 +840,18 @@ export class ActionFactoryService {
title: 'others', title: 'others',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [ children: [
{ {
action: Action.Delete, action: Action.Delete,
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -764,7 +859,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [Role.Download],
children: [], children: [],
}, },
] ]
@ -774,7 +871,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -785,7 +884,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-tooltip', description: 'edit-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -793,7 +894,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
class: 'danger', class: 'danger',
children: [], children: [],
}, },
@ -802,7 +905,9 @@ export class ActionFactoryService {
title: 'promote', title: 'promote',
description: 'promote-tooltip', description: 'promote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -810,7 +915,9 @@ export class ActionFactoryService {
title: 'unpromote', title: 'unpromote',
description: 'unpromote-tooltip', description: 'unpromote-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -821,7 +928,9 @@ export class ActionFactoryService {
title: 'edit', title: 'edit',
description: 'edit-person-tooltip', description: 'edit-person-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
}, },
{ {
@ -829,7 +938,9 @@ export class ActionFactoryService {
title: 'merge', title: 'merge',
description: 'merge-person-tooltip', description: 'merge-person-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: true, requiresAdmin: true,
requiredRoles: [Role.Admin],
children: [], children: [],
} }
]; ];
@ -840,7 +951,9 @@ export class ActionFactoryService {
title: 'view-series', title: 'view-series',
description: 'view-series-tooltip', description: 'view-series-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -848,7 +961,9 @@ export class ActionFactoryService {
title: 'download', title: 'download',
description: 'download-tooltip', description: 'download-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -856,8 +971,10 @@ export class ActionFactoryService {
title: 'clear', title: 'clear',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
class: 'danger', class: 'danger',
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -868,7 +985,9 @@ export class ActionFactoryService {
title: 'mark-visible', title: 'mark-visible',
description: 'mark-visible-tooltip', description: 'mark-visible-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -876,7 +995,9 @@ export class ActionFactoryService {
title: 'mark-invisible', title: 'mark-invisible',
description: 'mark-invisible-tooltip', description: 'mark-invisible-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -887,7 +1008,9 @@ export class ActionFactoryService {
title: 'rename', title: 'rename',
description: 'rename-tooltip', description: 'rename-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
{ {
@ -895,7 +1018,9 @@ export class ActionFactoryService {
title: 'delete', title: 'delete',
description: 'delete-tooltip', description: 'delete-tooltip',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
}, },
]; ];
@ -906,7 +1031,9 @@ export class ActionFactoryService {
title: 'reorder', title: 'reorder',
description: '', description: '',
callback: this.dummyCallback, callback: this.dummyCallback,
shouldRender: this.dummyShouldRender,
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
children: [], children: [],
} }
] ]
@ -914,21 +1041,24 @@ export class ActionFactoryService {
} }
private applyCallback(action: ActionItem<any>, callback: (action: ActionItem<any>, data: any) => void) { private applyCallback(action: ActionItem<any>, callback: ActionCallback<any>, shouldRenderFunc: ActionShouldRenderFunc<any>) {
action.callback = callback; action.callback = callback;
action.shouldRender = shouldRenderFunc;
if (action.children === null || action.children?.length === 0) return; if (action.children === null || action.children?.length === 0) return;
action.children?.forEach((childAction) => { action.children?.forEach((childAction) => {
this.applyCallback(childAction, callback); this.applyCallback(childAction, callback, shouldRenderFunc);
}); });
} }
public applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: ActionItem<any>, data: any) => void): Array<ActionItem<any>> { public applyCallbackToList(list: Array<ActionItem<any>>,
callback: ActionCallback<any>,
shouldRenderFunc: ActionShouldRenderFunc<any> = this.dummyShouldRender): Array<ActionItem<any>> {
const actions = list.map((a) => { const actions = list.map((a) => {
return { ...a }; return { ...a };
}); });
actions.forEach((action) => this.applyCallback(action, callback)); actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
return actions; return actions;
} }

View File

@ -473,8 +473,7 @@ export class ActionService {
} }
async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) { async deleteMultipleVolumes(volumes: Array<Volume>, callback?: BooleanActionCallback) {
// TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes" if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return;
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
if (callback) { if (callback) {

View File

@ -1,20 +1,19 @@
import { HttpClient } from '@angular/common/http'; import {HttpClient, HttpParams} from '@angular/common/http';
import {Inject, inject, Injectable} from '@angular/core'; import {Inject, inject, Injectable} from '@angular/core';
import { environment } from 'src/environments/environment'; import {environment} from 'src/environments/environment';
import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
import {asyncScheduler, finalize, map, tap} from 'rxjs'; import {asyncScheduler, map} from 'rxjs';
import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
import { TopUserRead } from '../statistics/_models/top-reads'; import {TopUserRead} from '../statistics/_models/top-reads';
import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; import {ReadHistoryEvent} from '../statistics/_models/read-history-event';
import { ServerStatistics } from '../statistics/_models/server-statistics'; import {ServerStatistics} from '../statistics/_models/server-statistics';
import { StatCount } from '../statistics/_models/stat-count'; import {StatCount} from '../statistics/_models/stat-count';
import { PublicationStatus } from '../_models/metadata/publication-status'; import {PublicationStatus} from '../_models/metadata/publication-status';
import { MangaFormat } from '../_models/manga-format'; import {MangaFormat} from '../_models/manga-format';
import { TextResonse } from '../_types/text-response'; import {TextResonse} from '../_types/text-response';
import {TranslocoService} from "@jsverse/transloco"; import {TranslocoService} from "@jsverse/transloco";
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
import {throttleTime} from "rxjs/operators"; import {throttleTime} from "rxjs/operators";
import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {DEBOUNCE_TIME} from "../shared/_services/download.service";
import {download} from "../shared/_models/download"; import {download} from "../shared/_models/download";
@ -44,11 +43,14 @@ export class StatisticsService {
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
getUserStatistics(userId: number, libraryIds: Array<number> = []) { getUserStatistics(userId: number, libraryIds: Array<number> = []) {
// TODO: Convert to httpParams object const url = `${this.baseUrl}stats/user/${userId}/read`;
let url = 'stats/user/' + userId + '/read';
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
return this.httpClient.get<UserReadStatistics>(this.baseUrl + url); let params = new HttpParams();
if (libraryIds.length > 0) {
params = params.set('libraryIds', libraryIds.join(','));
}
return this.httpClient.get<UserReadStatistics>(url, { params });
} }
getServerStatistics() { getServerStatistics() {
@ -59,7 +61,7 @@ export class StatisticsService {
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe( return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
map(spreads => spreads.map(spread => { map(spreads => spreads.map(spread => {
return {name: spread.value + '', value: spread.count}; return {name: spread.value + '', value: spread.count};
}))); })));
} }
getTopYears() { getTopYears() {

View File

@ -1,7 +1,9 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; read: 'actionable'">
<div class="modal-container"> <div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">{{t('title')}}</h4> <h4 class="modal-title">
{{t('title')}}
</h4>
<button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button> <button type="button" class="btn-close" aria-label="close" (click)="modal.close()"></button>
</div> </div>
<div class="modal-body scrollable-modal"> <div class="modal-body scrollable-modal">
@ -12,8 +14,6 @@
} }
<div class="d-grid gap-2"> <div class="d-grid gap-2">
@for (action of currentItems; track action.title) { @for (action of currentItems; track action.title) {
@if (willRenderAction(action)) { @if (willRenderAction(action)) {
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100" <button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"

View File

@ -1,18 +1,18 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {NgClass} from "@angular/common";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {Action, ActionItem} from "../../_services/action-factory.service"; import {ActionableEntity, ActionItem} from "../../_services/action-factory.service";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {tap} from "rxjs"; import {tap} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
protected readonly destroyRef = inject(DestroyRef); protected readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
@Input() entity: ActionableEntity = null;
@Input() actions: ActionItem<any>[] = []; @Input() actions: ActionItem<any>[] = [];
@Input() willRenderAction!: (action: ActionItem<any>) => boolean; @Input() willRenderAction!: (action: ActionItem<any>) => boolean;
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean; @Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;

View File

@ -1,51 +1,57 @@
<ng-container *transloco="let t; read: 'actionable'"> <ng-container *transloco="let t; read: 'actionable'">
@if (actions.length > 0) { @if (actions.length > 0) {
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) { @if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" <button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
(click)="openMobileActionableMenu($event)"> (click)="openMobileActionableMenu($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
} @else {
<div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)">
{{label}} {{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i> <i class="fa {{iconClass}}" aria-hidden="true"></i>
</button> </button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}"> } @else {
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container> <div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}" ngbDropdownToggle
(click)="preventEvent($event)">
{{label}}
<i class="fa {{iconClass}}" aria-hidden="true"></i>
</button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
</div>
</div> </div>
</div> <ng-template #submenu let-list="list">
<ng-template #submenu let-list="list"> @for(action of list; track action.title) {
@for(action of list; track action.title) { <!-- Non Submenu items -->
<!-- Non Submenu items --> @if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) { @if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) { @for(dynamicItem of dList; track dynamicItem.title) {
@for(dynamicItem of dList; track dynamicItem.title) { <button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
}
} @else if (willRenderAction(action)) {
<button ngbDropdownItem (click)="performAction($event, action)" (mouseover)="closeAllSubmenus()">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event); openSubmenu(action.title, subMenuHover)"
(mouseleave)="preventEvent($event)">
@if (willRenderAction(action)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
} }
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}"> } @else if (willRenderAction(action, this.currentUser!)) {
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container> <button ngbDropdownItem (click)="performAction($event, action)">{{t(action.title)}}</button>
}
} @else {
@if (shouldRenderSubMenu(action, action.children?.[0].dynamicList | async) && hasRenderableChildren(action, this.currentUser!)) {
<!-- Submenu items -->
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right left"
(click)="openSubmenu(action.title, subMenuHover)"
(mouseenter)="openSubmenu(action.title, subMenuHover)"
(mouseover)="preventEvent($event)"
class="submenu-wrapper">
<!-- Check to ensure the submenu has items -->
@if (willRenderAction(action, this.currentUser!)) {
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>
{{t(action.title)}} <i class="fa-solid fa-angle-right submenu-icon"></i>
</button>
}
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div>
</div> </div>
</div> }
} }
} }
} </ng-template>
</ng-template> }
} }
} </ng-container>
</ng-container>

View File

@ -2,6 +2,22 @@
content: none !important; content: none !important;
} }
.submenu-wrapper {
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
right: -10px;
width: 10px;
height: 100%;
background: transparent;
cursor: pointer;
pointer-events: auto;
}
}
.submenu-toggle { .submenu-toggle {
display: block; display: block;
width: 100%; width: 100%;
@ -30,9 +46,3 @@
.btn { .btn {
padding: 5px; padding: 5px;
} }
// Robbie added this but it broke most of the uses
//.dropdown-toggle {
// padding-top: 0;
// padding-bottom: 0;
//}

View File

@ -1,31 +1,39 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, DestroyRef, Component,
DestroyRef,
EventEmitter, EventEmitter,
inject, inject,
Input, Input,
OnChanges,
OnDestroy,
OnInit, OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap'; import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
import { AccountService } from 'src/app/_services/account.service'; import {AccountService} from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service'; import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
import {AsyncPipe, NgTemplateOutlet} from "@angular/common"; import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe"; import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component"; import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
import {User} from "../../_models/user";
@Component({ @Component({
selector: 'app-card-actionables', selector: 'app-card-actionables',
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet], imports: [
templateUrl: './card-actionables.component.html', NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
styleUrls: ['./card-actionables.component.scss'], DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
changeDetection: ChangeDetectionStrategy.OnPush ],
templateUrl: './card-actionables.component.html',
styleUrls: ['./card-actionables.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CardActionablesComponent implements OnInit { export class CardActionablesComponent implements OnInit, OnChanges, OnDestroy {
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
@Input() iconClass = 'fa-ellipsis-v'; @Input() iconClass = 'fa-ellipsis-v';
@Input() btnClass = ''; @Input() btnClass = '';
@Input() actions: ActionItem<any>[] = []; @Input() inputActions: ActionItem<any>[] = [];
@Input() labelBy = 'card'; @Input() labelBy = 'card';
/** /**
* Text to display as if actionable was a button * Text to display as if actionable was a button
*/ */
@Input() label = ''; @Input() label = '';
@Input() disabled: boolean = false; @Input() disabled: boolean = false;
@Input() entity: ActionableEntity = null;
/**
* This will only emit when the action is clicked and the entity is null. Otherwise, the entity callback handler will be invoked.
*/
@Output() actionHandler = new EventEmitter<ActionItem<any>>(); @Output() actionHandler = new EventEmitter<ActionItem<any>>();
isAdmin: boolean = false; actions: ActionItem<ActionableEntity>[] = [];
canDownload: boolean = false; currentUser: User | undefined = undefined;
canPromote: boolean = false;
submenu: {[key: string]: NgbDropdown} = {}; submenu: {[key: string]: NgbDropdown} = {};
private closeTimeout: any = null;
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
if (!user) return; if (!user) return;
this.isAdmin = this.accountService.hasAdminRole(user); this.currentUser = user;
this.canDownload = this.accountService.hasDownloadRole(user); this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
this.canPromote = this.accountService.hasPromoteRole(user);
// We want to avoid an empty menu when user doesn't have access to anything
if (!this.isAdmin && this.actions.filter(a => !a.requiresAdmin).length === 0) {
this.actions = [];
}
this.cdRef.markForCheck(); this.cdRef.markForCheck();
}); });
} }
ngOnChanges() {
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
this.cdRef.markForCheck();
}
ngOnDestroy() {
this.cancelCloseSubmenus();
}
preventEvent(event: any) { preventEvent(event: any) {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
} }
performAction(event: any, action: ActionItem<any>) { performAction(event: any, action: ActionItem<ActionableEntity>) {
this.preventEvent(event); this.preventEvent(event);
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
this.actionHandler.emit(action); if (this.entity === null) {
this.actionHandler.emit(action);
} else {
action.callback(action, this.entity);
}
} }
} }
willRenderAction(action: ActionItem<any>) { /**
return (action.requiresAdmin && this.isAdmin) * The user has required roles (or no roles defined) and action shouldRender returns true
|| (action.action === Action.Download && (this.canDownload || this.isAdmin)) * @param action
|| (!action.requiresAdmin && action.action !== Action.Download) * @param user
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin)) */
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin)) willRenderAction(action: ActionItem<ActionableEntity>, user: User) {
; return (!action.requiredRoles?.length || this.accountService.hasAnyRole(user, action.requiredRoles)) && action.shouldRender(action, this.entity, user);
} }
shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) { shouldRenderSubMenu(action: ActionItem<any>, dynamicList: null | Array<any>) {
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
} }
closeAllSubmenus() { closeAllSubmenus() {
Object.keys(this.submenu).forEach(key => { // Clear any existing timeout to avoid race conditions
this.submenu[key].close(); if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
}
// Set a new timeout to close submenus after a short delay
this.closeTimeout = setTimeout(() => {
Object.keys(this.submenu).forEach(key => {
this.submenu[key].close();
delete this.submenu[key]; delete this.submenu[key];
}); });
}, 100); // Small delay to prevent premature closing (dropdown tunneling)
} }
performDynamicClick(event: any, action: ActionItem<any>, dynamicItem: any) { cancelCloseSubmenus() {
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = null;
}
}
hasRenderableChildren(action: ActionItem<ActionableEntity>, user: User): boolean {
if (!action.children || action.children.length === 0) return false;
for (const child of action.children) {
const dynamicList = child.dynamicList;
if (dynamicList !== undefined) return true; // Dynamic list gets rendered if loaded
if (this.willRenderAction(child, user)) return true;
if (child.children?.length && this.hasRenderableChildren(child, user)) return true;
}
return false;
}
performDynamicClick(event: any, action: ActionItem<ActionableEntity>, dynamicItem: any) {
action._extra = dynamicItem; action._extra = dynamicItem;
this.performAction(event, action); this.performAction(event, action);
} }
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
this.preventEvent(event); this.preventEvent(event);
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true}); const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
ref.componentInstance.entity = this.entity;
ref.componentInstance.actions = this.actions; ref.componentInstance.actions = this.actions;
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this); ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this); ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);

View File

@ -1,7 +1,8 @@
<ng-container *transloco="let t; read: 'manage-library'"> <ng-container *transloco="let t; read: 'manage-library'">
<div class="position-relative"> <div class="position-relative">
<div class="position-absolute custom-position-2"> <div class="position-absolute custom-position-2">
<app-card-actionables [actions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')" [disabled]="bulkMode" (actionHandler)="handleBulkAction($event, null)"> <app-card-actionables [inputActions]="bulkActions" btnClass="btn-outline-primary ms-1" [label]="t('bulk-action-label')"
[disabled]="bulkMode">
</app-card-actionables> </app-card-actionables>
</div> </div>
@ -72,11 +73,22 @@
<td> <td>
<!-- On Mobile we want to use ... for each row --> <!-- On Mobile we want to use ... for each row -->
@if (useActionables$ | async) { @if (useActionables$ | async) {
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event, library)"></app-card-actionables> <app-card-actionables [entity]="library" [inputActions]="actions"></app-card-actionables>
} @else { } @else {
<button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')" [attr.aria-label]="t('scan-library')"><i class="fa fa-sync-alt" aria-hidden="true"></i></button> <button class="btn btn-secondary me-2 btn-sm" (click)="scanLibrary(library)" placement="top" [ngbTooltip]="t('scan-library')"
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)"><i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')" [attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i></button> [attr.aria-label]="t('scan-library')">
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)"><i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')" [attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i></button> <i class="fa fa-sync-alt" aria-hidden="true"></i>
</button>
<button class="btn btn-danger me-2 btn-sm" [disabled]="deletionInProgress" (click)="deleteLibrary(library)">
<i class="fa fa-trash" placement="top" [ngbTooltip]="t('delete-library')"
[attr.aria-label]="t('delete-library-by-name', {name: library.name | sentenceCase})"></i>
</button>
<button class="btn btn-primary btn-sm" (click)="editLibrary(library)">
<i class="fa fa-pen" placement="top" [ngbTooltip]="t('edit-library')"
[attr.aria-label]="t('edit-library-by-name', {name: library.name | sentenceCase})"></i>
</button>
} }
</td> </td>
</tr> </tr>

View File

@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
lastSelectedIndex: number | null = null; lastSelectedIndex: number | null = null;
@HostListener('document:keydown.shift', ['$event']) @HostListener('document:keydown.shift', ['$event'])
handleKeypress(event: KeyboardEvent) { handleKeypress(_: KeyboardEvent) {
this.isShiftDown = true; this.isShiftDown = true;
} }
@HostListener('document:keyup.shift', ['$event']) @HostListener('document:keyup.shift', ['$event'])
handleKeyUp(event: KeyboardEvent) { handleKeyUp(_: KeyboardEvent) {
this.isShiftDown = false; this.isShiftDown = false;
} }
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.getLibraries(); this.getLibraries();
// when a progress event comes in, show it on the UI next to library // when a progress event comes in, show it on the UI next to the library
this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef), this.hubService.messages$.pipe(takeUntilDestroyed(this.destroyRef),
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress), filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) => distinctUntilChanged((prev: Message<ScanSeriesEvent | NotificationProgressEvent>, curr: Message<ScanSeriesEvent | NotificationProgressEvent>) =>
@ -270,7 +270,8 @@ export class ManageLibraryComponent implements OnInit {
} }
} }
async handleBulkAction(action: ActionItem<Library>, library : Library | null) { async handleBulkAction(action: ActionItem<Library>, _: Library) {
//Library is null for bulk actions
this.bulkAction = action.action; this.bulkAction = action.action;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
break; break;
case (Action.CopySettings): case (Action.CopySettings):
// Prompt the user for the library then wait for them to manually trigger applyBulkAction // Prompt the user for the library, then wait for them to manually trigger applyBulkAction
const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'}); const ref = this.modalService.open(CopySettingsFromLibraryModalComponent, {size: 'lg', fullscreen: 'md'});
ref.componentInstance.libraries = this.libraries; ref.componentInstance.libraries = this.libraries;
ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => { ref.closed.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res: number | null) => {
@ -298,7 +299,6 @@ export class ManageLibraryComponent implements OnInit {
} }
} }
async handleAction(action: ActionItem<Library>, library: Library) { async handleAction(action: ActionItem<Library>, library: Library) {
switch (action.action) { switch (action.action) {
case(Action.Scan): case(Action.Scan):
@ -321,13 +321,6 @@ export class ManageLibraryComponent implements OnInit {
} }
} }
performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') {
action.callback(action, library);
}
}
setupSelections() { setupSelections() {
this.selections = new SelectionModel<Library>(false, this.libraries); this.selections = new SelectionModel<Library>(false, this.libraries);
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -1,11 +0,0 @@
@if (logs$ | async; as items) {
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
@for (item of scroll.viewPortItems; track item.timestamp) {
<div class="card col-auto mt-2 mb-2">
{{item.timestamp | date}} [{{item.level}}] {{item.message}}
</div>
}
</div>
</virtual-scroller>
}

View File

@ -1,71 +0,0 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { BehaviorSubject, take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service';
import { environment } from 'src/environments/environment';
import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller';
import { AsyncPipe, DatePipe } from '@angular/common';
interface LogMessage {
timestamp: string;
level: 'Information' | 'Debug' | 'Warning' | 'Error';
message: string;
exception: string;
}
@Component({
selector: 'app-manage-logs',
templateUrl: './manage-logs.component.html',
styleUrls: ['./manage-logs.component.scss'],
standalone: true,
imports: [VirtualScrollerModule, AsyncPipe, DatePipe]
})
export class ManageLogsComponent implements OnInit, OnDestroy {
hubUrl = environment.hubUrl;
private hubConnection!: HubConnection;
logsSource = new BehaviorSubject<LogMessage[]>([]);
public logs$ = this.logsSource.asObservable();
constructor(private accountService: AccountService) { }
ngOnInit(): void {
// TODO: Come back and implement this one day
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'logs', {
accessTokenFactory: () => user.token
})
.withAutomaticReconnect()
.build();
console.log('Starting log connection');
this.hubConnection
.start()
.catch(err => console.error(err));
this.hubConnection.on('SendLogAsObject', resp => {
const payload = resp.arguments[0] as LogMessage;
const logMessage = {timestamp: payload.timestamp, level: payload.level, message: payload.message, exception: payload.exception};
// NOTE: It might be better to just have a queue to show this
const values = this.logsSource.getValue();
values.push(logMessage);
this.logsSource.next(values);
});
}
});
}
ngOnDestroy(): void {
// unsubscribe from signalr connection
if (this.hubConnection) {
this.hubConnection.stop().catch(err => console.error(err));
console.log('Stopping log connection');
}
}
}

View File

@ -10,6 +10,4 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
export class UpdateSectionComponent { export class UpdateSectionComponent {
@Input({required: true}) items: Array<string> = []; @Input({required: true}) items: Array<string> = [];
@Input({required: true}) title: string = ''; @Input({required: true}) title: string = '';
// TODO: Implement a read-more-list so that we by default show a configurable number
} }

View File

@ -23,7 +23,7 @@
<span class="visually-hidden">{{t('mark-as-read')}}</span> <span class="visually-hidden">{{t('mark-as-read')}}</span>
</button> </button>
} }
<app-card-actionables [actions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" labelBy="bulk-actions-header" iconClass="fa-ellipsis-h" />
</span> </span>
<span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span> <span id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>

View File

@ -2,13 +2,14 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, HostListener, DestroyRef,
HostListener,
inject, inject,
Input, Input,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service'; import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import { BulkSelectionService } from '../bulk-selection.service'; import {BulkSelectionService} from '../bulk-selection.service';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common"; import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common";
import {TranslocoModule} from "@jsverse/transloco"; import {TranslocoModule} from "@jsverse/transloco";
@ -17,18 +18,18 @@ import {CardActionablesComponent} from "../../_single-module/card-actionables/ca
import {KEY_CODES} from "../../shared/_services/utility.service"; import {KEY_CODES} from "../../shared/_services/utility.service";
@Component({ @Component({
selector: 'app-bulk-operations', selector: 'app-bulk-operations',
imports: [ imports: [
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
TranslocoModule, TranslocoModule,
NgbTooltip, NgbTooltip,
NgStyle, NgStyle,
DecimalPipe DecimalPipe
], ],
templateUrl: './bulk-operations.component.html', templateUrl: './bulk-operations.component.html',
styleUrls: ['./bulk-operations.component.scss'], styleUrls: ['./bulk-operations.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class BulkOperationsComponent implements OnInit { export class BulkOperationsComponent implements OnInit {

View File

@ -7,7 +7,7 @@
<h4> <h4>
@if (actions.length > 0) { @if (actions.length > 0) {
<span> <span>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>&nbsp; <app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>&nbsp;
</span> </span>
} }

View File

@ -94,7 +94,7 @@
<span class="card-actions"> <span class="card-actions">
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables> <app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
} }
</span> </span>
</div> </div>

View File

@ -344,10 +344,6 @@ export class CardItemComponent implements OnInit {
this.clicked.emit(this.title); this.clicked.emit(this.title);
} }
preventClick(event: any) {
event.stopPropagation();
event.preventDefault();
}
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (action.action == Action.Download) { if (action.action == Action.Download) {

View File

@ -89,7 +89,7 @@
</span> </span>
<span class="card-actions"> <span class="card-actions">
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="chapter.titleName"></app-card-actionables> <app-card-actionables [entity]="chapter" [inputActions]="actions" [labelBy]="chapter.titleName"></app-card-actionables>
} }
</span> </span>
</div> </div>

View File

@ -3,9 +3,11 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef, DestroyRef,
EventEmitter, HostListener, EventEmitter,
HostListener,
inject, inject,
Input, OnInit, Input,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import {ImageService} from "../../_services/image.service"; import {ImageService} from "../../_services/image.service";
@ -14,7 +16,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {ActionItem} from "../../_services/action-factory.service";
import {Chapter} from "../../_models/chapter"; import {Chapter} from "../../_models/chapter";
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -28,13 +30,10 @@ import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {Router, RouterLink} from "@angular/router"; import {Router, RouterLink} from "@angular/router";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {filter, map} from "rxjs/operators"; import {filter, map} from "rxjs/operators";
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event"; import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {LibraryType} from "../../_models/library/library"; import {LibraryType} from "../../_models/library/library";
import {Device} from "../../_models/device/device";
import {ActionService} from "../../_services/action.service";
import {MangaFormat} from "../../_models/manga-format"; import {MangaFormat} from "../../_models/manga-format";
@Component({ @Component({
@ -60,15 +59,16 @@ export class ChapterCardComponent implements OnInit {
public readonly imageService = inject(ImageService); public readonly imageService = inject(ImageService);
public readonly bulkSelectionService = inject(BulkSelectionService); public readonly bulkSelectionService = inject(BulkSelectionService);
private readonly downloadService = inject(DownloadService); private readonly downloadService = inject(DownloadService);
private readonly actionService = inject(ActionService);
private readonly messageHub = inject(MessageHubService); private readonly messageHub = inject(MessageHubService);
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly scrollService = inject(ScrollService); private readonly scrollService = inject(ScrollService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly readerService = inject(ReaderService); private readonly readerService = inject(ReaderService);
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
@Input({required: true}) libraryId: number = 0; @Input({required: true}) libraryId: number = 0;
@Input({required: true}) seriesId: number = 0; @Input({required: true}) seriesId: number = 0;
@Input({required: true}) chapter!: Chapter; @Input({required: true}) chapter!: Chapter;
@ -143,8 +143,6 @@ export class ChapterCardComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user; this.user = user;
}); });
@ -172,30 +170,6 @@ export class ChapterCardComponent implements OnInit {
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.chapter);
}
performAction(action: ActionItem<any>) {
if (action.action == Action.Download) {
this.downloadService.download('chapter', this.chapter);
return; // Don't propagate the download from a card
}
if (action.action == Action.SendTo) {
const device = (action._extra!.data as Device);
this.actionService.sendToDevice([this.chapter.id], device);
return;
}
if (typeof action.callback === 'function') {
action.callback(action, this.chapter);
}
}
handleClick(event: any) { handleClick(event: any) {
if (this.bulkSelectionService.hasSelections()) { if (this.bulkSelectionService.hasSelections()) {
this.handleSelection(event); this.handleSelection(event);
@ -209,8 +183,4 @@ export class ChapterCardComponent implements OnInit {
event.stopPropagation(); event.stopPropagation();
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false); this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
} }
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
} }

View File

@ -32,7 +32,7 @@
</span> </span>
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions float-end"> <span class="card-actions float-end">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="title"></app-card-actionables> <app-card-actionables [entity]="entity" [inputActions]="actions" [labelBy]="title"></app-card-actionables>
</span> </span>
} }
</div> </div>

View File

@ -1,20 +1,20 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, ContentChild, Component,
DestroyRef, EventEmitter, ContentChild,
DestroyRef,
EventEmitter,
HostListener, HostListener,
inject, inject,
Input, Output, TemplateRef Input,
Output,
TemplateRef
} from '@angular/core'; } from '@angular/core';
import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; import {ActionItem} from "../../_services/action-factory.service";
import {ImageService} from "../../_services/image.service"; import {ImageService} from "../../_services/image.service";
import {BulkSelectionService} from "../bulk-selection.service"; import {BulkSelectionService} from "../bulk-selection.service";
import {LibraryService} from "../../_services/library.service";
import {DownloadService} from "../../shared/_services/download.service";
import {UtilityService} from "../../shared/_services/utility.service";
import {MessageHubService} from "../../_services/message-hub.service"; import {MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
@ -139,11 +139,6 @@ export class PersonCardComponent {
this.clicked.emit(this.title); this.clicked.emit(this.title);
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.entity);
}
}
handleSelection(event?: any) { handleSelection(event?: any) {
if (event) { if (event) {

View File

@ -74,7 +74,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions"> <span class="card-actions">
<app-card-actionables (actionHandler)="handleSeriesActionCallback($event, series)" [actions]="actions" [labelBy]="series.name"></app-card-actionables> <app-card-actionables [entity]="series" [inputActions]="actions" [labelBy]="series.name"></app-card-actionables>
</span> </span>
} }
</div> </div>

View File

@ -22,7 +22,6 @@ import {ActionService} from 'src/app/_services/action.service';
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component'; import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
import {RelationKind} from 'src/app/_models/series-detail/relation-kind'; import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
import {DecimalPipe} from "@angular/common"; import {DecimalPipe} from "@angular/common";
import {CardItemComponent} from "../card-item/card-item.component";
import {RelationshipPipe} from "../../_pipes/relationship.pipe"; import {RelationshipPipe} from "../../_pipes/relationship.pipe";
import {Device} from "../../_models/device/device"; import {Device} from "../../_models/device/device";
import {translate, TranslocoDirective} from "@jsverse/transloco"; import {translate, TranslocoDirective} from "@jsverse/transloco";
@ -30,7 +29,6 @@ import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component"; import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
import {EntityTitleComponent} from "../entity-title/entity-title.component";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {ImageComponent} from "../../shared/image/image.component"; import {ImageComponent} from "../../shared/image/image.component";
import {DownloadEvent, DownloadService} from "../../shared/_services/download.service"; import {DownloadEvent, DownloadService} from "../../shared/_services/download.service";
@ -39,7 +37,6 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map} from "rxjs/operators"; import {map} from "rxjs/operators";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {BulkSelectionService} from "../bulk-selection.service"; import {BulkSelectionService} from "../bulk-selection.service";
import {User} from "../../_models/user";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
@ -147,8 +144,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
*/ */
prevOffset: number = 0; prevOffset: number = 0;
selectionInProgress: boolean = false; selectionInProgress: boolean = false;
private user: User | undefined;
@HostListener('touchmove', ['$event']) @HostListener('touchmove', ['$event'])
onTouchMove(event: TouchEvent) { onTouchMove(event: TouchEvent) {
@ -192,15 +187,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
ngOnChanges(changes: any) { ngOnChanges(changes: any) {
if (this.series) { if (this.series) {
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { // this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user; // this.user = user;
}); // });
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
return this.downloadService.mapToEntityType(events, this.series); return this.downloadService.mapToEntityType(events, this.series);
})); }));
this.actions = [...this.actionFactoryService.getSeriesActions((action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series))]; this.actions = [...this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))];
if (this.isOnDeck) { if (this.isOnDeck) {
const othersIndex = this.actions.findIndex(obj => obj.title === 'others'); const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>; const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
@ -209,9 +204,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
action: Action.RemoveFromOnDeck, action: Action.RemoveFromOnDeck,
title: 'remove-from-on-deck', title: 'remove-from-on-deck',
description: '', description: '',
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series), callback: this.handleSeriesActionCallback.bind(this),
class: 'danger', class: 'danger',
requiresAdmin: false, requiresAdmin: false,
requiredRoles: [],
shouldRender: (_, _2, _3) => true,
children: [], children: [],
}); });
this.actions[othersIndex] = othersAction; this.actions[othersIndex] = othersAction;

View File

@ -81,7 +81,7 @@
@if (actions && actions.length > 0) { @if (actions && actions.length > 0) {
<span class="card-actions"> <span class="card-actions">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="volume.name"></app-card-actionables> <app-card-actionables [entity]="volume" [inputActions]="actions" [labelBy]="volume.name"></app-card-actionables>
</span> </span>
} }
</div> </div>

View File

@ -23,7 +23,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {ScrollService} from "../../_services/scroll.service"; import {ScrollService} from "../../_services/scroll.service";
import {Action, ActionItem} from "../../_services/action-factory.service"; import {ActionItem} from "../../_services/action-factory.service";
import {ReaderService} from "../../_services/reader.service"; import {ReaderService} from "../../_services/reader.service";
import {Observable} from "rxjs"; import {Observable} from "rxjs";
import {User} from "../../_models/user"; import {User} from "../../_models/user";
@ -33,7 +33,6 @@ import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update
import {Volume} from "../../_models/volume"; import {Volume} from "../../_models/volume";
import {UtilityService} from "../../shared/_services/utility.service"; import {UtilityService} from "../../shared/_services/utility.service";
import {LibraryType} from "../../_models/library/library"; import {LibraryType} from "../../_models/library/library";
import {Device} from "../../_models/device/device";
import {ActionService} from "../../_services/action.service"; import {ActionService} from "../../_services/action.service";
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
@ -143,8 +142,6 @@ export class VolumeCardComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.filterSendTo();
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
this.user = user; this.user = user;
}); });
@ -180,30 +177,6 @@ export class VolumeCardComponent implements OnInit {
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
filterSendTo() {
if (!this.actions || this.actions.length === 0) return;
// TODO: See if we can handle send to for volumes
//this.actions = this.actionFactoryService.filterSendToAction(this.actions, this.volume);
}
performAction(action: ActionItem<Volume>) {
if (action.action == Action.Download) {
this.downloadService.download('volume', this.volume);
return; // Don't propagate the download from a card
}
if (action.action == Action.SendTo) {
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(this.volume.chapters.map(c => c.id), device);
return;
}
if (typeof action.callback === 'function') {
action.callback(action, this.volume);
}
}
handleClick(event: any) { handleClick(event: any) {
if (this.bulkSelectionService.hasSelections()) { if (this.bulkSelectionService.hasSelections()) {
this.handleSelection(event); this.handleSelection(event);

View File

@ -4,7 +4,7 @@
<div class="carousel-container mb-3"> <div class="carousel-container mb-3">
<div> <div>
@if (actionables.length > 0) { @if (actionables.length > 0) {
<app-card-actionables [actions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables [inputActions]="actionables" (actionHandler)="performAction($event)"></app-card-actionables>
} }
<h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}"> <h4 class="header" (click)="sectionClicked($event)" [ngClass]="{'non-selectable': !clickableTitle}">
@if (titleLink !== '') { @if (titleLink !== '') {

View File

@ -74,7 +74,7 @@
<div class="col-auto ms-2k"> <div class="col-auto ms-2k">
<div class="card-actions" [ngbTooltip]="t('more-alt')"> <div class="card-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables> <app-card-actionables [entity]="chapter" [inputActions]="chapterActions" [labelBy]="series.name + ' ' + chapter.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
</div> </div>
</div> </div>

View File

@ -83,7 +83,7 @@ enum TabID {
} }
@Component({ @Component({
selector: 'app-chapter-detail', selector: 'app-chapter-detail',
imports: [ imports: [
AsyncPipe, AsyncPipe,
CardActionablesComponent, CardActionablesComponent,
@ -116,9 +116,9 @@ enum TabID {
ReviewsComponent, ReviewsComponent,
ExternalRatingComponent ExternalRatingComponent
], ],
templateUrl: './chapter-detail.component.html', templateUrl: './chapter-detail.component.html',
styleUrl: './chapter-detail.component.scss', styleUrl: './chapter-detail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ChapterDetailComponent implements OnInit { export class ChapterDetailComponent implements OnInit {
@ -339,10 +339,6 @@ export class ChapterDetailComponent implements OnInit {
this.location.replaceState(newUrl) this.location.replaceState(newUrl)
} }
openPerson(field: FilterField, value: number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
downloadChapter() { downloadChapter() {
if (this.downloadInProgress) return; if (this.downloadInProgress) return;
this.downloadService.download('chapter', this.chapter!, (d) => { this.downloadService.download('chapter', this.chapter!, (d) => {
@ -360,11 +356,6 @@ export class ChapterDetailComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
performAction(action: ActionItem<Chapter>) {
if (typeof action.callback === 'function') {
action.callback(action, this.chapter!);
}
}
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) { handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) { switch (action.action) {

View File

@ -6,11 +6,9 @@
<ng-container title> <ng-container title>
@if (collectionTag) { @if (collectionTag) {
<h4> <h4>
{{collectionTag.title}} <app-promoted-icon [promoted]="collectionTag.promoted"></app-promoted-icon>
@if(collectionTag.promoted) { <span class="ms-2">{{collectionTag.title}}</span>
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span> <app-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
}
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
</h4> </h4>
} }
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5> <h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>

View File

@ -61,6 +61,7 @@ import {
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component"; } from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
import {DefaultModalOptions} from "../../../_models/default-modal-options"; import {DefaultModalOptions} from "../../../_models/default-modal-options";
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe"; import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
@Component({ @Component({
selector: 'app-collection-detail', selector: 'app-collection-detail',
@ -69,7 +70,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent, imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip, BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe] DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe, PromotedIconComponent]
}) })
export class CollectionDetailComponent implements OnInit, AfterContentChecked { export class CollectionDetailComponent implements OnInit, AfterContentChecked {
@ -304,12 +305,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.collectionTag);
}
}
openEditCollectionTagModal(collectionTag: UserCollection) { openEditCollectionTagModal(collectionTag: UserCollection) {
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions); const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
modalRef.componentInstance.tag = this.collectionTag; modalRef.componentInstance.tag = this.collectionTag;
@ -320,7 +315,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
} }
openSyncDetailDrawer() { openSyncDetailDrawer() {
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''}); const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
ref.componentInstance.collection = this.collectionTag; ref.componentInstance.collection = this.collectionTag;
ref.componentInstance.series = this.series; ref.componentInstance.series = this.series;

View File

@ -3,7 +3,7 @@
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive"> <app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
<h4 title> <h4 title>
<span>{{libraryName}}</span> <span>{{libraryName}}</span>
<app-card-actionables [actions]="actions" (actionHandler)="performAction($event)"></app-card-actionables> <app-card-actionables [inputActions]="actions" (actionHandler)="performAction($event)"></app-card-actionables>
</h4> </h4>
@if (active.fragment === '') { @if (active.fragment === '') {
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5> <h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
@ -31,7 +31,6 @@
</ng-template> </ng-template>
<ng-template #noData> <ng-template #noData>
<!-- TODO: Come back and figure this out -->
{{t('common.no-data')}} {{t('common.no-data')}}
</ng-template> </ng-template>
</app-card-detail-layout> </app-card-detail-layout>

View File

@ -297,8 +297,6 @@ export class LibraryDetailComponent implements OnInit {
} }
} }
performAction(action: ActionItem<any>) { performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
action.callback(action, undefined); action.callback(action, undefined);

View File

@ -15,6 +15,10 @@
} @else { } @else {
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button> <button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
} }
} @else {
<div class="input-hint">
Ctrl+K
</div>
} }
</div> </div>
</div> </div>

View File

@ -9,6 +9,17 @@
right: 5px; right: 5px;
} }
.input-hint {
font-size: 0.8rem;
margin-top: 3px;
margin-bottom: 3px;
margin-right: 9px;
border: 1px solid var(--input-hint-border-color, lightgrey);
color: var(--input-hint-text-color);
border-radius: 4px;
padding: 2px;
}
.typeahead-input { .typeahead-input {
border: 1px solid transparent; border: 1px solid transparent;

View File

@ -100,7 +100,9 @@ export class GroupedTypeaheadComponent implements OnInit {
hasFocus: boolean = false; hasFocus: boolean = false;
typeaheadForm: FormGroup = new FormGroup({}); typeaheadForm: FormGroup = new FormGroup({
typeahead: new FormControl('', []),
});
includeChapterAndFiles: boolean = false; includeChapterAndFiles: boolean = false;
prevSearchTerm: string = ''; prevSearchTerm: string = '';
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)})); searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
@ -121,22 +123,37 @@ export class GroupedTypeaheadComponent implements OnInit {
this.close(); this.close();
} }
@HostListener('window:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent) { handleKeyPress(event: KeyboardEvent) {
if (!this.hasFocus) { return; }
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
switch(event.key) { switch(event.key) {
case KEY_CODES.ESC_KEY: case KEY_CODES.ESC_KEY:
if (!this.hasFocus) { return; }
this.close(); this.close();
event.stopPropagation(); event.stopPropagation();
break; break;
case KEY_CODES.K:
if (isCtrlOrMeta) {
if (this.inputElem.nativeElement) {
event.preventDefault();
event.stopPropagation();
this.inputElem.nativeElement.focus();
this.inputElem.nativeElement.click();
}
}
break;
default: default:
break; break;
} }
} }
ngOnInit(): void { ngOnInit(): void {
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, [])); this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe( this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(

View File

@ -5,7 +5,7 @@
<app-side-nav-companion-bar> <app-side-nav-companion-bar>
<ng-container title> <ng-container title>
<h2 class="title text-break"> <h2 class="title text-break">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables> <app-card-actionables [entity]="person" [inputActions]="personActions" [labelBy]="person.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{person.name}}</span> <span>{{person.name}}</span>
@if (person.aniListId) { @if (person.aniListId) {

View File

@ -19,7 +19,6 @@ import {
SideNavCompanionBarComponent SideNavCompanionBarComponent
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component";
import {ReadMoreComponent} from "../shared/read-more/read-more.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component";
import {TagBadgeCursor} from "../shared/tag-badge/tag-badge.component";
import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {PersonRolePipe} from "../_pipes/person-role.pipe";
import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
@ -89,7 +88,7 @@ export class PersonDetailComponent implements OnInit {
private readonly toastr = inject(ToastrService); private readonly toastr = inject(ToastrService);
private readonly messageHubService = inject(MessageHubService) private readonly messageHubService = inject(MessageHubService)
protected readonly TagBadgeCursor = TagBadgeCursor; protected readonly FilterField = FilterField;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -278,11 +277,4 @@ export class PersonDetailComponent implements OnInit {
}); });
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.person);
}
}
protected readonly FilterField = FilterField;
} }

View File

@ -10,12 +10,10 @@
<div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12"> <div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12">
<h4 class="title mb-2"> <h4 class="title mb-2">
<span>{{readingList.title}} <span>
@if (readingList.promoted) { <app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>
(<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>) <span class="ms-2">{{readingList.title}}</span>
} @if(isLoading) {
@if( isLoading) {
<div class="spinner-border spinner-border-sm text-primary" role="status"> <div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">loading...</span> <span class="visually-hidden">loading...</span>
</div> </div>
@ -87,7 +85,7 @@
<div class="col-auto ms-2 d-none d-md-block"> <div class="col-auto ms-2 d-none d-md-block">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')"> <div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="readingList.title" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables> <app-card-actionables [entity]="readingList" [inputActions]="actions" [labelBy]="readingList.title" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
</div> </div>
</div> </div>

View File

@ -273,11 +273,6 @@ export class ReadingListDetailComponent implements OnInit {
}); });
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.readingList);
}
}
readChapter(item: ReadingListItem) { readChapter(item: ReadingListItem) {
if (!this.readingList) return; if (!this.readingList) return;
@ -387,12 +382,6 @@ export class ReadingListDetailComponent implements OnInit {
{queryParams: {readingListId: this.readingList.id, incognitoMode: incognitoMode}}); {queryParams: {readingListId: this.readingList.id, incognitoMode: incognitoMode}});
} }
updateAccessibilityMode() {
this.accessibilityMode = !this.accessibilityMode;
this.cdRef.markForCheck();
}
toggleReorder() { toggleReorder() {
this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value); this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value);
this.cdRef.markForCheck(); this.cdRef.markForCheck();

View File

@ -3,7 +3,7 @@
<app-side-nav-companion-bar> <app-side-nav-companion-bar>
<h4 title> <h4 title>
<span>{{t('title')}}</span> <span>{{t('title')}}</span>
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables> <app-card-actionables [inputActions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
</h4> </h4>
@if (pagination) { @if (pagination) {
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h5> <h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h5>

View File

@ -96,7 +96,7 @@
<div class="col-auto ms-2"> <div class="col-auto ms-2">
<div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')"> <div class="card-actions btn-actions" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables> <app-card-actionables [entity]="series" [inputActions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn"></app-card-actionables>
</div> </div>
</div> </div>

View File

@ -61,11 +61,6 @@ import {ReaderService} from 'src/app/_services/reader.service';
import {ReadingListService} from 'src/app/_services/reading-list.service'; import {ReadingListService} from 'src/app/_services/reading-list.service';
import {ScrollService} from 'src/app/_services/scroll.service'; import {ScrollService} from 'src/app/_services/scroll.service';
import {SeriesService} from 'src/app/_services/series.service'; import {SeriesService} from 'src/app/_services/series.service';
import {
ReviewModalCloseAction,
ReviewModalCloseEvent,
ReviewModalComponent
} from '../../../_single-module/review-modal/review-modal.component';
import {PageLayoutMode} from 'src/app/_models/page-layout-mode'; import {PageLayoutMode} from 'src/app/_models/page-layout-mode';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {UserReview} from "../../../_single-module/review-card/user-review"; import {UserReview} from "../../../_single-module/review-card/user-review";
@ -73,8 +68,6 @@ import {ExternalSeriesCardComponent} from '../../../cards/external-series-card/e
import {SeriesCardComponent} from '../../../cards/series-card/series-card.component'; import {SeriesCardComponent} from '../../../cards/series-card/series-card.component';
import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component'; import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component';
import {ReviewCardComponent} from '../../../_single-module/review-card/review-card.component';
import {CarouselReelComponent} from '../../../carousel/_components/carousel-reel/carousel-reel.component';
import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco";
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
import {PublicationStatus} from "../../../_models/metadata/publication-status"; import {PublicationStatus} from "../../../_models/metadata/publication-status";
@ -1138,13 +1131,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}); });
} }
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.series);
}
}
downloadSeries() { downloadSeries() {
this.downloadService.download('series', this.series, (d) => { this.downloadService.download('series', this.series, (d) => {
this.downloadInProgress = !!d; this.downloadInProgress = !!d;

View File

@ -1,12 +1,12 @@
import { HttpParams } from '@angular/common/http'; import {HttpParams} from '@angular/common/http';
import { Injectable } from '@angular/core'; import {Injectable} from '@angular/core';
import { Chapter } from 'src/app/_models/chapter'; import {Chapter} from 'src/app/_models/chapter';
import { LibraryType } from 'src/app/_models/library/library'; import {LibraryType} from 'src/app/_models/library/library';
import { MangaFormat } from 'src/app/_models/manga-format'; import {MangaFormat} from 'src/app/_models/manga-format';
import { PaginatedResult } from 'src/app/_models/pagination'; import {PaginatedResult} from 'src/app/_models/pagination';
import { Series } from 'src/app/_models/series'; import {Series} from 'src/app/_models/series';
import { Volume } from 'src/app/_models/volume'; import {Volume} from 'src/app/_models/volume';
import {translate, TranslocoService} from "@jsverse/transloco"; import {translate} from "@jsverse/transloco";
import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; import {debounceTime, ReplaySubject, shareReplay} from "rxjs";
export enum KEY_CODES { export enum KEY_CODES {
@ -21,6 +21,7 @@ export enum KEY_CODES {
B = 'b', B = 'b',
F = 'f', F = 'f',
H = 'h', H = 'h',
K = 'k',
BACKSPACE = 'Backspace', BACKSPACE = 'Backspace',
DELETE = 'Delete', DELETE = 'Delete',
SHIFT = 'Shift' SHIFT = 'Shift'
@ -41,6 +42,9 @@ export class UtilityService {
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1); public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true}));
// TODO: I need an isPhone/Tablet so that I can easily trigger different views
mangaFormatKeys: string[] = []; mangaFormatKeys: string[] = [];

View File

@ -8,8 +8,7 @@
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-home" [title]="t('home')" link="/home/"> <app-side-nav-item cdkDrag cdkDragDisabled icon="fa-home" [title]="t('home')" link="/home/">
<ng-container actions> <ng-container actions>
<app-card-actionables [actions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v" <app-card-actionables [inputActions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v" (actionHandler)="performHomeAction($event)" />
(actionHandler)="performHomeAction($event)" />
</ng-container> </ng-container>
</app-side-nav-item> </app-side-nav-item>
@ -44,8 +43,7 @@
[imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name" [imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name"
[comparisonMethod]="'startsWith'"> [comparisonMethod]="'startsWith'">
<ng-container actions> <ng-container actions>
<app-card-actionables [actions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v" <app-card-actionables [entity]="navStream.library" [inputActions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"></app-card-actionables>
(actionHandler)="performAction($event, navStream.library!)"></app-card-actionables>
</ng-container> </ng-container>
</app-side-nav-item> </app-side-nav-item>
} }

View File

@ -155,24 +155,25 @@ export class SideNavComponent implements OnInit {
} }
async handleAction(action: ActionItem<Library>, library: Library) { async handleAction(action: ActionItem<Library>, library: Library) {
const lib = library;
switch (action.action) { switch (action.action) {
case(Action.Scan): case(Action.Scan):
await this.actionService.scanLibrary(library); await this.actionService.scanLibrary(lib);
break; break;
case(Action.RefreshMetadata): case(Action.RefreshMetadata):
await this.actionService.refreshLibraryMetadata(library); await this.actionService.refreshLibraryMetadata(lib);
break; break;
case(Action.GenerateColorScape): case(Action.GenerateColorScape):
await this.actionService.refreshLibraryMetadata(library, undefined, false); await this.actionService.refreshLibraryMetadata(lib, undefined, false);
break; break;
case (Action.AnalyzeFiles): case (Action.AnalyzeFiles):
await this.actionService.analyzeFiles(library); await this.actionService.analyzeFiles(lib);
break; break;
case (Action.Delete): case (Action.Delete):
await this.actionService.deleteLibrary(library); await this.actionService.deleteLibrary(lib);
break; break;
case (Action.Edit): case (Action.Edit):
this.actionService.editLibrary(library, () => window.scrollTo(0, 0)); this.actionService.editLibrary(lib, () => window.scrollTo(0, 0));
break; break;
default: default:
break; break;
@ -191,6 +192,7 @@ export class SideNavComponent implements OnInit {
performAction(action: ActionItem<Library>, library: Library) { performAction(action: ActionItem<Library>, library: Library) {
if (typeof action.callback === 'function') { if (typeof action.callback === 'function') {
console.log('library: ', library)
action.callback(action, library); action.callback(action, library);
} }
} }

View File

@ -257,12 +257,12 @@ export class LibrarySettingsModalComponent implements OnInit {
// TODO: Refactor into FormArray // TODO: Refactor into FormArray
for(let fileTypeGroup of allFileTypeGroup) { for(let fileTypeGroup of allFileTypeGroup) {
this.libraryForm.addControl(fileTypeGroup + '', new FormControl(this.library.libraryFileTypes.includes(fileTypeGroup), [])); this.libraryForm.addControl(fileTypeGroup + '', new FormControl((this.library.libraryFileTypes || []).includes(fileTypeGroup), []));
} }
// TODO: Refactor into FormArray // TODO: Refactor into FormArray
for(let glob of this.library.excludePatterns) { for(let glob of this.library.excludePatterns) {
this.libraryForm.addControl('excludeGlob-' , new FormControl(glob, [])); this.libraryForm.addControl('excludeGlob-', new FormControl(glob, []));
} }
this.excludePatterns = this.library.excludePatterns; this.excludePatterns = this.library.excludePatterns;

View File

@ -77,7 +77,7 @@
<div class="col-auto ms-2"> <div class="col-auto ms-2">
<div class="card-actions mt-2" [ngbTooltip]="t('more-alt')"> <div class="card-actions mt-2" [ngbTooltip]="t('more-alt')">
<app-card-actionables (actionHandler)="performAction($event)" [actions]="volumeActions" [labelBy]="series.name + ' ' + volume.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables> <app-card-actionables [entity]="volume" [inputActions]="volumeActions" [labelBy]="series.name + ' ' + volume.minNumber" iconClass="fa-ellipsis-h" btnClass="btn-actions btn"></app-card-actionables>
</div> </div>
</div> </div>

View File

@ -80,6 +80,7 @@ import {UserReview} from "../_single-module/review-card/user-review";
import {ReviewsComponent} from "../_single-module/reviews/reviews.component"; import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component"; import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
import {ChapterService} from "../_services/chapter.service"; import {ChapterService} from "../_services/chapter.service";
import {User} from "../_models/user";
enum TabID { enum TabID {
@ -187,6 +188,7 @@ export class VolumeDetailComponent implements OnInit {
protected readonly TabID = TabID; protected readonly TabID = TabID;
protected readonly FilterField = FilterField; protected readonly FilterField = FilterField;
protected readonly Breakpoint = Breakpoint; protected readonly Breakpoint = Breakpoint;
protected readonly encodeURIComponent = encodeURIComponent;
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined; @ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined; @ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
@ -211,7 +213,7 @@ export class VolumeDetailComponent implements OnInit {
mobileSeriesImgBackground: string | undefined; mobileSeriesImgBackground: string | undefined;
downloadInProgress: boolean = false; downloadInProgress: boolean = false;
volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this)); volumeActions: Array<ActionItem<Volume>> = this.actionFactoryService.getVolumeActions(this.handleVolumeAction.bind(this), this.shouldRenderVolumeAction.bind(this));
chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); chapterActions: Array<ActionItem<Chapter>> = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));
bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => { bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => {
@ -570,16 +572,6 @@ export class VolumeDetailComponent implements OnInit {
this.location.replaceState(newUrl) this.location.replaceState(newUrl)
} }
openPerson(field: FilterField, value: number) {
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
}
performAction(action: ActionItem<Volume>) {
if (typeof action.callback === 'function') {
action.callback(action, this.volume!);
}
}
async handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) { async handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
switch (action.action) { switch (action.action) {
case(Action.MarkAsRead): case(Action.MarkAsRead):
@ -610,6 +602,17 @@ export class VolumeDetailComponent implements OnInit {
} }
} }
shouldRenderVolumeAction(action: ActionItem<Volume>, entity: Volume, user: User) {
switch (action.action) {
case(Action.MarkAsRead):
return entity.pagesRead < entity.pages;
case(Action.MarkAsUnread):
return entity.pagesRead !== 0;
default:
return true;
}
}
async handleVolumeAction(action: ActionItem<Volume>) { async handleVolumeAction(action: ActionItem<Volume>) {
switch (action.action) { switch (action.action) {
case Action.Delete: case Action.Delete:
@ -687,6 +690,4 @@ export class VolumeDetailComponent implements OnInit {
this.currentlyReadingChapter = undefined; this.currentlyReadingChapter = undefined;
} }
} }
protected readonly encodeURIComponent = encodeURIComponent;
} }

View File

@ -439,4 +439,8 @@
/** Series Detail **/ /** Series Detail **/
--detail-subtitle-color: lightgrey; --detail-subtitle-color: lightgrey;
/** Search **/
--input-hint-border-color: #aeaeae;
--input-hint-text-color: lightgrey;
} }

View File

@ -94,9 +94,11 @@ Package()
fi fi
echo "Copying appsettings.json" echo "Copying appsettings.json"
cp config/appsettings.json $lOutputFolder/config/appsettings.json cp config/appsettings.json $lOutputFolder/config/appsettings-init.json
echo "Removing appsettings.Development.json" echo "Removing appsettings.Development.json"
rm $lOutputFolder/config/appsettings.Development.json rm $lOutputFolder/config/appsettings.Development.json
echo "Removing appsettings.json"
rm $lOutputFolder/config/appsettings.json
echo "Creating tar" echo "Creating tar"
cd ../$outputFolder/"$runtime"/ cd ../$outputFolder/"$runtime"/