mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
Random Stuff (#3798)
This commit is contained in:
parent
574cf4b78e
commit
70f00895e8
@ -9,10 +9,10 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
|
||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.13" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="22.0.14" />
|
||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.0.14" />
|
||||
<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>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
@ -51,7 +51,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@ -66,7 +66,7 @@
|
||||
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<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="Hangfire.AspNetCore" Version="1.8.18" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.2.0" />
|
||||
@ -78,7 +78,7 @@
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
|
||||
<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="Serilog" Version="4.2.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.Sinks.AspNetCore.SignalR" Version="0.4.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="SharpCompress" Version="0.39.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.8.0.113526">
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
|
||||
<PackageReference Include="SonarAnalyzer.CSharp" Version="10.9.0.115408">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.8.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.13" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.3" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="22.0.14" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -111,17 +111,16 @@
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Hangfire-log.db" />
|
||||
<None Remove="obj\**" />
|
||||
<None Remove="cache\**" />
|
||||
<None Remove="cache-long\**" />
|
||||
<None Remove="backups\**" />
|
||||
<None Remove="logs\**" />
|
||||
<None Remove="temp\**" />
|
||||
<None Remove="kavita.log" />
|
||||
<None Remove="kavita.db" />
|
||||
<None Remove="covers\**" />
|
||||
<None Remove="config\kavita.log" />
|
||||
<None Remove="config\kavita.db" />
|
||||
<None Remove="config\covers\**" />
|
||||
<None Remove="wwwroot\**" />
|
||||
<None Remove="cache\cache-long\**" />
|
||||
<None Remove="config\cache\**" />
|
||||
<None Remove="config\logs\**" />
|
||||
<None Remove="config\covers\**" />
|
||||
@ -139,6 +138,7 @@
|
||||
<Compile Remove="covers\**" />
|
||||
<Compile Remove="wwwroot\**" />
|
||||
<Compile Remove="config\cache\**" />
|
||||
<Compile Remove="cache\cache-long\**" />
|
||||
<Compile Remove="config\logs\**" />
|
||||
<Compile Remove="config\covers\**" />
|
||||
<Compile Remove="config\bookmarks\**" />
|
||||
@ -188,7 +188,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="config\cache-long\" />
|
||||
<Folder Include="config\themes" />
|
||||
<Content Include="EmailTemplates\**">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
|
@ -4,14 +4,18 @@ using API.DTOs.Scrobbling;
|
||||
namespace API.DTOs.KavitaPlus.ExternalMetadata;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to match some series from Kavita to an external id which K+ uses.
|
||||
/// </summary>
|
||||
internal sealed record MatchSeriesRequestDto
|
||||
{
|
||||
public string SeriesName { get; set; }
|
||||
public ICollection<string> AlternativeNames { get; set; }
|
||||
public required string SeriesName { get; set; }
|
||||
public ICollection<string> AlternativeNames { get; set; } = [];
|
||||
public int Year { get; set; } = 0;
|
||||
public string Query { get; set; }
|
||||
public string? Query { get; set; }
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public string? HardcoverId { get; set; }
|
||||
public int? CbrId { get; set; }
|
||||
public PlusMediaFormat Format { get; set; }
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next)
|
||||
}
|
||||
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 requestPath = context.Request.Path;
|
||||
var userAgent = context.Request.Headers.UserAgent;
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
@ -48,15 +49,13 @@ public class Program
|
||||
|
||||
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
|
||||
if (!Configuration.CheckIfJwtTokenSet() &&
|
||||
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);
|
||||
}
|
||||
EnsureJwtTokenKey();
|
||||
|
||||
try
|
||||
{
|
||||
@ -70,6 +69,7 @@ public class Program
|
||||
{
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
var context = services.GetRequiredService<DataContext>();
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
||||
var isDbCreated = await context.Database.CanConnectAsync();
|
||||
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)
|
||||
{
|
||||
string? currentVersion = null;
|
||||
|
@ -226,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(),
|
||||
Year = series.Metadata.ReleaseYear,
|
||||
AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series),
|
||||
MalId = potentialMalId ?? ScrobblingService.GetMalId(series),
|
||||
MalId = potentialMalId ?? ScrobblingService.GetMalId(series)
|
||||
};
|
||||
|
||||
var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken;
|
||||
@ -792,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
var characters = externalCharacters
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
@ -873,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
var artists = upstreamArtists
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
@ -929,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
var writers = upstreamWriters
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name,
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingService.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
@ -1353,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
var people = staff!
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w,
|
||||
Name = w.Trim(),
|
||||
})
|
||||
.Concat(chapter.People
|
||||
.Where(p => p.Role == role)
|
||||
|
@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
person.CoverImage = Path.GetFileName(existingPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
@ -651,6 +651,7 @@ public class CoverDbService : ICoverDbService
|
||||
else
|
||||
{
|
||||
_directoryService.DeleteFiles([tempFullPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.CoverImage = finalFileName;
|
||||
|
@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher
|
||||
if (rootFolder.Count == 0) return string.Empty;
|
||||
|
||||
// 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]));
|
||||
}
|
||||
|
||||
|
||||
|
@ -52,6 +52,7 @@ public interface IVersionUpdaterService
|
||||
Task PushUpdate(UpdateNotificationDto update);
|
||||
Task<IList<UpdateNotificationDto>> GetAllReleases(int count = 0);
|
||||
Task<int> GetNumberOfReleasesBehind(bool stableOnly = false);
|
||||
void BustGithubCache();
|
||||
}
|
||||
|
||||
|
||||
@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||
if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration)
|
||||
{
|
||||
var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath);
|
||||
return System.Text.Json.JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
|
||||
return JsonSerializer.Deserialize<UpdateNotificationDto>(cachedData);
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions);
|
||||
var json = JsonSerializer.Serialize(update, JsonOptions);
|
||||
await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService
|
||||
.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)
|
||||
{
|
||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
||||
|
@ -55,6 +55,9 @@ public class Startup
|
||||
{
|
||||
_config = config;
|
||||
_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.
|
||||
@ -223,7 +226,7 @@ public class Startup
|
||||
// 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,
|
||||
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>>();
|
||||
@ -235,9 +238,10 @@ public class Startup
|
||||
// Apply all migrations on startup
|
||||
var dataContext = serviceProvider.GetRequiredService<DataContext>();
|
||||
|
||||
|
||||
logger.LogInformation("Running Migrations");
|
||||
|
||||
#region Migrations
|
||||
|
||||
// v0.7.9
|
||||
await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger);
|
||||
|
||||
@ -289,13 +293,23 @@ public class Startup
|
||||
await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger);
|
||||
await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger);
|
||||
|
||||
#endregion
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
|
||||
var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString();
|
||||
installVersion.Value = BuildInfo.Version.ToString();
|
||||
unitOfWork.SettingsRepository.Update(installVersion);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
logger.LogInformation("Running Migrations - complete");
|
||||
|
||||
if (isVersionDifferent)
|
||||
{
|
||||
// Clear the Github cache so update stuff shows correctly
|
||||
versionService.BustGithubCache();
|
||||
}
|
||||
|
||||
}).GetAwaiter()
|
||||
.GetResult();
|
||||
}
|
||||
|
@ -14,7 +14,7 @@
|
||||
<PackageReference Include="Flurl.Http" Version="4.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" 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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
@ -7,12 +7,13 @@ import {Library} from '../_models/library/library';
|
||||
import {ReadingList} from '../_models/reading-list';
|
||||
import {Series} from '../_models/series';
|
||||
import {Volume} from '../_models/volume';
|
||||
import {AccountService} from './account.service';
|
||||
import {AccountService, Role} from './account.service';
|
||||
import {DeviceService} from './device.service';
|
||||
import {SideNavStream} from "../_models/sidenav/sidenav-stream";
|
||||
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {Person} from "../_models/metadata/person";
|
||||
import {User} from '../_models/user';
|
||||
|
||||
export enum Action {
|
||||
Submenu = -1,
|
||||
@ -106,7 +107,7 @@ export enum Action {
|
||||
Promote = 24,
|
||||
UnPromote = 25,
|
||||
/**
|
||||
* Invoke a refresh covers as false to generate colorscapes
|
||||
* Invoke refresh covers as false to generate colorscapes
|
||||
*/
|
||||
GenerateColorScape = 26,
|
||||
/**
|
||||
@ -126,14 +127,21 @@ export enum Action {
|
||||
/**
|
||||
* Callback for an action
|
||||
*/
|
||||
export type ActionCallback<T> = (action: ActionItem<T>, data: T) => void;
|
||||
export type ActionAllowedCallback<T> = (action: ActionItem<T>) => boolean;
|
||||
export type ActionCallback<T> = (action: ActionItem<T>, entity: T) => void;
|
||||
export type ActionShouldRenderFunc<T> = (action: ActionItem<T>, entity: T, user: User) => boolean;
|
||||
|
||||
export interface ActionItem<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
action: Action;
|
||||
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;
|
||||
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?: {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({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ActionFactoryService {
|
||||
libraryActions: Array<ActionItem<Library>> = [];
|
||||
|
||||
seriesActions: Array<ActionItem<Series>> = [];
|
||||
|
||||
volumeActions: Array<ActionItem<Volume>> = [];
|
||||
|
||||
chapterActions: Array<ActionItem<Chapter>> = [];
|
||||
|
||||
collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
||||
|
||||
readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||
|
||||
bookmarkActions: Array<ActionItem<Series>> = [];
|
||||
|
||||
private libraryActions: Array<ActionItem<Library>> = [];
|
||||
private seriesActions: Array<ActionItem<Series>> = [];
|
||||
private volumeActions: Array<ActionItem<Volume>> = [];
|
||||
private chapterActions: Array<ActionItem<Chapter>> = [];
|
||||
private collectionTagActions: Array<ActionItem<UserCollection>> = [];
|
||||
private readingListActions: Array<ActionItem<ReadingList>> = [];
|
||||
private bookmarkActions: Array<ActionItem<Series>> = [];
|
||||
private personActions: Array<ActionItem<Person>> = [];
|
||||
|
||||
sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
|
||||
sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||
|
||||
isAdmin = false;
|
||||
|
||||
private sideNavStreamActions: Array<ActionItem<SideNavStream>> = [];
|
||||
private smartFilterActions: Array<ActionItem<SmartFilter>> = [];
|
||||
private sideNavHomeActions: Array<ActionItem<void>> = [];
|
||||
|
||||
constructor(private accountService: AccountService, private deviceService: DeviceService) {
|
||||
this.accountService.currentUser$.subscribe((user) => {
|
||||
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.accountService.currentUser$.subscribe((_) => {
|
||||
this._resetActions();
|
||||
});
|
||||
}
|
||||
|
||||
getLibraryActions(callback: ActionCallback<Library>) {
|
||||
return this.applyCallbackToList(this.libraryActions, callback);
|
||||
getLibraryActions(callback: ActionCallback<Library>, shouldRenderFunc: ActionShouldRenderFunc<Library> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem<Library>[];
|
||||
}
|
||||
|
||||
getSeriesActions(callback: ActionCallback<Series>) {
|
||||
return this.applyCallbackToList(this.seriesActions, callback);
|
||||
getSeriesActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.basicReadRender) {
|
||||
return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getSideNavStreamActions(callback: ActionCallback<SideNavStream>) {
|
||||
return this.applyCallbackToList(this.sideNavStreamActions, callback);
|
||||
getSideNavStreamActions(callback: ActionCallback<SideNavStream>, shouldRenderFunc: ActionShouldRenderFunc<SideNavStream> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getSmartFilterActions(callback: ActionCallback<SmartFilter>) {
|
||||
return this.applyCallbackToList(this.smartFilterActions, callback);
|
||||
getSmartFilterActions(callback: ActionCallback<SmartFilter>, shouldRenderFunc: ActionShouldRenderFunc<SmartFilter> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getVolumeActions(callback: ActionCallback<Volume>) {
|
||||
return this.applyCallbackToList(this.volumeActions, callback);
|
||||
getVolumeActions(callback: ActionCallback<Volume>, shouldRenderFunc: ActionShouldRenderFunc<Volume> = this.basicReadRender) {
|
||||
return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getChapterActions(callback: ActionCallback<Chapter>) {
|
||||
return this.applyCallbackToList(this.chapterActions, callback);
|
||||
getChapterActions(callback: ActionCallback<Chapter>, shouldRenderFunc: ActionShouldRenderFunc<Chapter> = this.basicReadRender) {
|
||||
return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getCollectionTagActions(callback: ActionCallback<UserCollection>) {
|
||||
return this.applyCallbackToList(this.collectionTagActions, callback);
|
||||
getCollectionTagActions(callback: ActionCallback<UserCollection>, shouldRenderFunc: ActionShouldRenderFunc<UserCollection> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getReadingListActions(callback: ActionCallback<ReadingList>) {
|
||||
return this.applyCallbackToList(this.readingListActions, callback);
|
||||
getReadingListActions(callback: ActionCallback<ReadingList>, shouldRenderFunc: ActionShouldRenderFunc<ReadingList> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getBookmarkActions(callback: ActionCallback<Series>) {
|
||||
return this.applyCallbackToList(this.bookmarkActions, callback);
|
||||
getBookmarkActions(callback: ActionCallback<Series>, shouldRenderFunc: ActionShouldRenderFunc<Series> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getPersonActions(callback: ActionCallback<Person>) {
|
||||
return this.applyCallbackToList(this.personActions, callback);
|
||||
getPersonActions(callback: ActionCallback<Person>, shouldRenderFunc: ActionShouldRenderFunc<Person> = this.dummyShouldRender) {
|
||||
return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc);
|
||||
}
|
||||
|
||||
getSideNavHomeActions(callback: ActionCallback<void>) {
|
||||
return this.applyCallbackToList(this.sideNavHomeActions, callback);
|
||||
getSideNavHomeActions(callback: ActionCallback<void>, shouldRenderFunc: ActionShouldRenderFunc<void> = this.dummyShouldRender) {
|
||||
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) {
|
||||
// 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));
|
||||
}
|
||||
|
||||
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
|
||||
const actions = this.flattenActions<Library>(this.libraryActions).filter(a => {
|
||||
@ -293,11 +305,13 @@ export class ActionFactoryService {
|
||||
dynamicList: undefined,
|
||||
action: Action.CopySettings,
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: shouldRenderFunc,
|
||||
children: [],
|
||||
requiredRoles: [Role.Admin],
|
||||
requiresAdmin: true,
|
||||
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>> {
|
||||
@ -323,7 +337,9 @@ export class ActionFactoryService {
|
||||
title: 'scan-library',
|
||||
description: 'scan-library-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -331,14 +347,18 @@ export class ActionFactoryService {
|
||||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [
|
||||
{
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'refresh-covers',
|
||||
description: 'refresh-covers-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -346,7 +366,9 @@ export class ActionFactoryService {
|
||||
title: 'generate-colorscape',
|
||||
description: 'generate-colorscape-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -354,7 +376,9 @@ export class ActionFactoryService {
|
||||
title: 'analyze-files',
|
||||
description: 'analyze-files-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -362,7 +386,9 @@ export class ActionFactoryService {
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
@ -372,7 +398,9 @@ export class ActionFactoryService {
|
||||
title: 'settings',
|
||||
description: 'settings-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -383,7 +411,9 @@ export class ActionFactoryService {
|
||||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -391,7 +421,9 @@ export class ActionFactoryService {
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
@ -400,7 +432,9 @@ export class ActionFactoryService {
|
||||
title: 'promote',
|
||||
description: 'promote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -408,7 +442,9 @@ export class ActionFactoryService {
|
||||
title: 'unpromote',
|
||||
description: 'unpromote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -419,7 +455,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-as-read',
|
||||
description: 'mark-as-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -427,7 +465,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-as-unread',
|
||||
description: 'mark-as-unread-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -435,7 +475,9 @@ export class ActionFactoryService {
|
||||
title: 'scan-series',
|
||||
description: 'scan-series-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -443,14 +485,18 @@ export class ActionFactoryService {
|
||||
title: 'add-to',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.AddToWantToReadList,
|
||||
title: 'add-to-want-to-read',
|
||||
description: 'add-to-want-to-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -458,7 +504,9 @@ export class ActionFactoryService {
|
||||
title: 'remove-from-want-to-read',
|
||||
description: 'remove-to-want-to-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -466,7 +514,9 @@ export class ActionFactoryService {
|
||||
title: 'add-to-reading-list',
|
||||
description: 'add-to-reading-list-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -474,26 +524,11 @@ export class ActionFactoryService {
|
||||
title: 'add-to-collection',
|
||||
description: 'add-to-collection-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
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',
|
||||
description: 'send-to-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
@ -521,14 +560,18 @@ export class ActionFactoryService {
|
||||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.RefreshMetadata,
|
||||
title: 'refresh-covers',
|
||||
description: 'refresh-covers-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -536,7 +579,9 @@ export class ActionFactoryService {
|
||||
title: 'generate-colorscape',
|
||||
description: 'generate-colorscape-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -544,7 +589,9 @@ export class ActionFactoryService {
|
||||
title: 'analyze-files',
|
||||
description: 'analyze-files-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -552,7 +599,9 @@ export class ActionFactoryService {
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
@ -563,7 +612,9 @@ export class ActionFactoryService {
|
||||
title: 'match',
|
||||
description: 'match-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -571,7 +622,9 @@ export class ActionFactoryService {
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [Role.Download],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -579,7 +632,9 @@ export class ActionFactoryService {
|
||||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -590,7 +645,9 @@ export class ActionFactoryService {
|
||||
title: 'read-incognito',
|
||||
description: 'read-incognito-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -598,7 +655,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-as-read',
|
||||
description: 'mark-as-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -606,7 +665,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-as-unread',
|
||||
description: 'mark-as-unread-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -614,14 +675,18 @@ export class ActionFactoryService {
|
||||
title: 'add-to',
|
||||
description: '=',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.AddToReadingList,
|
||||
title: 'add-to-reading-list',
|
||||
description: 'add-to-reading-list-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
}
|
||||
]
|
||||
@ -631,14 +696,18 @@ export class ActionFactoryService {
|
||||
title: 'send-to',
|
||||
description: 'send-to-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
@ -651,14 +720,18 @@ export class ActionFactoryService {
|
||||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -666,7 +739,9 @@ export class ActionFactoryService {
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
@ -676,7 +751,9 @@ export class ActionFactoryService {
|
||||
title: 'details',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -687,7 +764,9 @@ export class ActionFactoryService {
|
||||
title: 'read-incognito',
|
||||
description: 'read-incognito-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -695,7 +774,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-as-read',
|
||||
description: 'mark-as-read-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -703,7 +784,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-as-unread',
|
||||
description: 'mark-as-unread-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -711,14 +794,18 @@ export class ActionFactoryService {
|
||||
title: 'add-to',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.AddToReadingList,
|
||||
title: 'add-to-reading-list',
|
||||
description: 'add-to-reading-list-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
}
|
||||
]
|
||||
@ -728,14 +815,18 @@ export class ActionFactoryService {
|
||||
title: 'send-to',
|
||||
description: 'send-to-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.SendTo,
|
||||
title: '',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
|
||||
return {'title': d.name, 'data': d};
|
||||
}), shareReplay())),
|
||||
@ -749,14 +840,18 @@ export class ActionFactoryService {
|
||||
title: 'others',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.Delete,
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -764,7 +859,9 @@ export class ActionFactoryService {
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [Role.Download],
|
||||
children: [],
|
||||
},
|
||||
]
|
||||
@ -774,7 +871,9 @@ export class ActionFactoryService {
|
||||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -785,7 +884,9 @@ export class ActionFactoryService {
|
||||
title: 'edit',
|
||||
description: 'edit-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -793,7 +894,9 @@ export class ActionFactoryService {
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
class: 'danger',
|
||||
children: [],
|
||||
},
|
||||
@ -802,7 +905,9 @@ export class ActionFactoryService {
|
||||
title: 'promote',
|
||||
description: 'promote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -810,7 +915,9 @@ export class ActionFactoryService {
|
||||
title: 'unpromote',
|
||||
description: 'unpromote-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -821,7 +928,9 @@ export class ActionFactoryService {
|
||||
title: 'edit',
|
||||
description: 'edit-person-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -829,7 +938,9 @@ export class ActionFactoryService {
|
||||
title: 'merge',
|
||||
description: 'merge-person-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: true,
|
||||
requiredRoles: [Role.Admin],
|
||||
children: [],
|
||||
}
|
||||
];
|
||||
@ -840,7 +951,9 @@ export class ActionFactoryService {
|
||||
title: 'view-series',
|
||||
description: 'view-series-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -848,7 +961,9 @@ export class ActionFactoryService {
|
||||
title: 'download',
|
||||
description: 'download-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -856,8 +971,10 @@ export class ActionFactoryService {
|
||||
title: 'clear',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
class: 'danger',
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -868,7 +985,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-visible',
|
||||
description: 'mark-visible-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -876,7 +995,9 @@ export class ActionFactoryService {
|
||||
title: 'mark-invisible',
|
||||
description: 'mark-invisible-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -887,7 +1008,9 @@ export class ActionFactoryService {
|
||||
title: 'rename',
|
||||
description: 'rename-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
@ -895,7 +1018,9 @@ export class ActionFactoryService {
|
||||
title: 'delete',
|
||||
description: 'delete-tooltip',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@ -906,7 +1031,9 @@ export class ActionFactoryService {
|
||||
title: 'reorder',
|
||||
description: '',
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
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.shouldRender = shouldRenderFunc;
|
||||
|
||||
if (action.children === null || action.children?.length === 0) return;
|
||||
|
||||
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) => {
|
||||
return { ...a };
|
||||
});
|
||||
actions.forEach((action) => this.applyCallback(action, callback));
|
||||
actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc));
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
@ -473,8 +473,7 @@ export class ActionService {
|
||||
}
|
||||
|
||||
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-chapters', {count: volumes.length}))) return;
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return;
|
||||
|
||||
this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => {
|
||||
if (callback) {
|
||||
|
@ -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 { environment } from 'src/environments/environment';
|
||||
import { UserReadStatistics } from '../statistics/_models/user-read-statistics';
|
||||
import { PublicationStatusPipe } from '../_pipes/publication-status.pipe';
|
||||
import {asyncScheduler, finalize, map, tap} from 'rxjs';
|
||||
import { MangaFormatPipe } from '../_pipes/manga-format.pipe';
|
||||
import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown';
|
||||
import { TopUserRead } from '../statistics/_models/top-reads';
|
||||
import { ReadHistoryEvent } from '../statistics/_models/read-history-event';
|
||||
import { ServerStatistics } from '../statistics/_models/server-statistics';
|
||||
import { StatCount } from '../statistics/_models/stat-count';
|
||||
import { PublicationStatus } from '../_models/metadata/publication-status';
|
||||
import { MangaFormat } from '../_models/manga-format';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {UserReadStatistics} from '../statistics/_models/user-read-statistics';
|
||||
import {PublicationStatusPipe} from '../_pipes/publication-status.pipe';
|
||||
import {asyncScheduler, map} from 'rxjs';
|
||||
import {MangaFormatPipe} from '../_pipes/manga-format.pipe';
|
||||
import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown';
|
||||
import {TopUserRead} from '../statistics/_models/top-reads';
|
||||
import {ReadHistoryEvent} from '../statistics/_models/read-history-event';
|
||||
import {ServerStatistics} from '../statistics/_models/server-statistics';
|
||||
import {StatCount} from '../statistics/_models/stat-count';
|
||||
import {PublicationStatus} from '../_models/metadata/publication-status';
|
||||
import {MangaFormat} from '../_models/manga-format';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown";
|
||||
import {throttleTime} from "rxjs/operators";
|
||||
import {DEBOUNCE_TIME} from "../shared/_services/download.service";
|
||||
import {download} from "../shared/_models/download";
|
||||
@ -44,11 +43,14 @@ export class StatisticsService {
|
||||
constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { }
|
||||
|
||||
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
|
||||
// TODO: Convert to httpParams object
|
||||
let url = 'stats/user/' + userId + '/read';
|
||||
if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(',');
|
||||
const url = `${this.baseUrl}stats/user/${userId}/read`;
|
||||
|
||||
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() {
|
||||
@ -59,7 +61,7 @@ export class StatisticsService {
|
||||
return this.httpClient.get<StatCount<number>[]>(this.baseUrl + 'stats/server/count/year').pipe(
|
||||
map(spreads => spreads.map(spread => {
|
||||
return {name: spread.value + '', value: spread.count};
|
||||
})));
|
||||
})));
|
||||
}
|
||||
|
||||
getTopYears() {
|
||||
|
@ -1,7 +1,9 @@
|
||||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
<div class="modal-container">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body scrollable-modal">
|
||||
@ -12,8 +14,6 @@
|
||||
}
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
|
||||
|
||||
@for (action of currentItems; track action.title) {
|
||||
@if (willRenderAction(action)) {
|
||||
<button class="btn btn-outline-primary text-start d-flex justify-content-between align-items-center w-100"
|
||||
|
@ -1,18 +1,18 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {NgClass} from "@angular/common";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
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 {tap} from "rxjs";
|
||||
import {User} from "../../_models/user";
|
||||
@ -36,6 +36,7 @@ export class ActionableModalComponent implements OnInit {
|
||||
protected readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
|
||||
@Input() entity: ActionableEntity = null;
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() willRenderAction!: (action: ActionItem<any>) => boolean;
|
||||
@Input() shouldRenderSubMenu!: (action: ActionItem<any>, dynamicList: null | Array<any>) => boolean;
|
||||
|
@ -1,51 +1,57 @@
|
||||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
@if (actions.length > 0) {
|
||||
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
|
||||
(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)">
|
||||
<ng-container *transloco="let t; read: 'actionable'">
|
||||
@if (actions.length > 0) {
|
||||
@if ((utilityService.activeBreakpoint$ | async)! <= Breakpoint.Tablet) {
|
||||
<button [disabled]="disabled" class="btn {{btnClass}} px-3" id="actions-{{labelBy}}"
|
||||
(click)="openMobileActionableMenu($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>
|
||||
} @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}}
|
||||
<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>
|
||||
<ng-template #submenu let-list="list">
|
||||
@for(action of list; track action.title) {
|
||||
<!-- Non Submenu items -->
|
||||
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
|
||||
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
|
||||
@for(dynamicItem of dList; track dynamicItem.title) {
|
||||
<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>
|
||||
<ng-template #submenu let-list="list">
|
||||
@for(action of list; track action.title) {
|
||||
<!-- Non Submenu items -->
|
||||
@if (action.children === undefined || action?.children?.length === 0 || action.dynamicList !== undefined) {
|
||||
@if (action.dynamicList !== undefined && (action.dynamicList | async | dynamicList); as dList) {
|
||||
@for(dynamicItem of dList; track dynamicItem.title) {
|
||||
<button ngbDropdownItem (click)="performDynamicClick($event, action, dynamicItem)">{{dynamicItem.title}}</button>
|
||||
}
|
||||
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
|
||||
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
|
||||
} @else if (willRenderAction(action, this.currentUser!)) {
|
||||
<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>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
@ -2,6 +2,22 @@
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -30,9 +46,3 @@
|
||||
.btn {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
// Robbie added this but it broke most of the uses
|
||||
//.dropdown-toggle {
|
||||
// padding-top: 0;
|
||||
// padding-bottom: 0;
|
||||
//}
|
||||
|
@ -1,31 +1,39 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, DestroyRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {NgbDropdown, NgbDropdownItem, NgbDropdownMenu, NgbDropdownToggle, NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import {AccountService} from 'src/app/_services/account.service';
|
||||
import {ActionableEntity, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {AsyncPipe, NgTemplateOutlet} from "@angular/common";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DynamicListPipe} from "./_pipes/dynamic-list.pipe";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
|
||||
import {ActionableModalComponent} from "../actionable-modal/actionable-modal.component";
|
||||
import {User} from "../../_models/user";
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-actionables',
|
||||
imports: [NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet],
|
||||
templateUrl: './card-actionables.component.html',
|
||||
styleUrls: ['./card-actionables.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-card-actionables',
|
||||
imports: [
|
||||
NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem,
|
||||
DynamicListPipe, TranslocoDirective, AsyncPipe, NgTemplateOutlet
|
||||
],
|
||||
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 accountService = inject(AccountService);
|
||||
@ -37,58 +45,69 @@ export class CardActionablesComponent implements OnInit {
|
||||
|
||||
@Input() iconClass = 'fa-ellipsis-v';
|
||||
@Input() btnClass = '';
|
||||
@Input() actions: ActionItem<any>[] = [];
|
||||
@Input() inputActions: ActionItem<any>[] = [];
|
||||
@Input() labelBy = 'card';
|
||||
/**
|
||||
* Text to display as if actionable was a button
|
||||
*/
|
||||
@Input() label = '';
|
||||
@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>>();
|
||||
|
||||
|
||||
isAdmin: boolean = false;
|
||||
canDownload: boolean = false;
|
||||
canPromote: boolean = false;
|
||||
actions: ActionItem<ActionableEntity>[] = [];
|
||||
currentUser: User | undefined = undefined;
|
||||
submenu: {[key: string]: NgbDropdown} = {};
|
||||
private closeTimeout: any = null;
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
|
||||
if (!user) return;
|
||||
this.isAdmin = this.accountService.hasAdminRole(user);
|
||||
this.canDownload = this.accountService.hasDownloadRole(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.currentUser = user;
|
||||
this.actions = this.inputActions.filter(a => this.willRenderAction(a, user!));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.actions = this.inputActions.filter(a => this.willRenderAction(a, this.currentUser!));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.cancelCloseSubmenus();
|
||||
}
|
||||
|
||||
preventEvent(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(event: any, action: ActionItem<any>) {
|
||||
performAction(event: any, action: ActionItem<ActionableEntity>) {
|
||||
this.preventEvent(event);
|
||||
|
||||
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)
|
||||
|| (action.action === Action.Download && (this.canDownload || this.isAdmin))
|
||||
|| (!action.requiresAdmin && action.action !== Action.Download)
|
||||
|| (action.action === Action.Promote && (this.canPromote || this.isAdmin))
|
||||
|| (action.action === Action.UnPromote && (this.canPromote || this.isAdmin))
|
||||
;
|
||||
/**
|
||||
* The user has required roles (or no roles defined) and action shouldRender returns true
|
||||
* @param action
|
||||
* @param user
|
||||
*/
|
||||
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>) {
|
||||
@ -109,13 +128,41 @@ export class CardActionablesComponent implements OnInit {
|
||||
}
|
||||
|
||||
closeAllSubmenus() {
|
||||
Object.keys(this.submenu).forEach(key => {
|
||||
this.submenu[key].close();
|
||||
// Clear any existing timeout to avoid race conditions
|
||||
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];
|
||||
});
|
||||
});
|
||||
}, 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;
|
||||
this.performAction(event, action);
|
||||
}
|
||||
@ -124,6 +171,7 @@ export class CardActionablesComponent implements OnInit {
|
||||
this.preventEvent(event);
|
||||
|
||||
const ref = this.modalService.open(ActionableModalComponent, {fullscreen: true, centered: true});
|
||||
ref.componentInstance.entity = this.entity;
|
||||
ref.componentInstance.actions = this.actions;
|
||||
ref.componentInstance.willRenderAction = this.willRenderAction.bind(this);
|
||||
ref.componentInstance.shouldRenderSubMenu = this.shouldRenderSubMenu.bind(this);
|
||||
|
@ -1,7 +1,8 @@
|
||||
<ng-container *transloco="let t; read: 'manage-library'">
|
||||
<div class="position-relative">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -72,11 +73,22 @@
|
||||
<td>
|
||||
<!-- On Mobile we want to use ... for each row -->
|
||||
@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 {
|
||||
<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-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>
|
||||
<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-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>
|
||||
</tr>
|
||||
|
@ -83,12 +83,12 @@ export class ManageLibraryComponent implements OnInit {
|
||||
lastSelectedIndex: number | null = null;
|
||||
|
||||
@HostListener('document:keydown.shift', ['$event'])
|
||||
handleKeypress(event: KeyboardEvent) {
|
||||
handleKeypress(_: KeyboardEvent) {
|
||||
this.isShiftDown = true;
|
||||
}
|
||||
|
||||
@HostListener('document:keyup.shift', ['$event'])
|
||||
handleKeyUp(event: KeyboardEvent) {
|
||||
handleKeyUp(_: KeyboardEvent) {
|
||||
this.isShiftDown = false;
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ export class ManageLibraryComponent implements OnInit {
|
||||
ngOnInit(): void {
|
||||
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),
|
||||
filter(event => event.event === EVENTS.ScanSeries || event.event === EVENTS.NotificationProgress),
|
||||
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.cdRef.markForCheck();
|
||||
|
||||
@ -284,7 +285,7 @@ export class ManageLibraryComponent implements OnInit {
|
||||
break;
|
||||
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'});
|
||||
ref.componentInstance.libraries = this.libraries;
|
||||
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) {
|
||||
switch (action.action) {
|
||||
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() {
|
||||
this.selections = new SelectionModel<Library>(false, this.libraries);
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -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>
|
||||
}
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -10,6 +10,4 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
|
||||
export class UpdateSectionComponent {
|
||||
@Input({required: true}) items: Array<string> = [];
|
||||
@Input({required: true}) title: string = '';
|
||||
|
||||
// TODO: Implement a read-more-list so that we by default show a configurable number
|
||||
}
|
||||
|
@ -23,7 +23,7 @@
|
||||
<span class="visually-hidden">{{t('mark-as-read')}}</span>
|
||||
</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 id="bulk-actions-header" class="visually-hidden">Bulk Actions</span>
|
||||
|
@ -2,13 +2,14 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef, HostListener,
|
||||
DestroyRef,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { BulkSelectionService } from '../bulk-selection.service';
|
||||
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
|
||||
import {BulkSelectionService} from '../bulk-selection.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {AsyncPipe, DecimalPipe, NgStyle} from "@angular/common";
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-bulk-operations',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
TranslocoModule,
|
||||
NgbTooltip,
|
||||
NgStyle,
|
||||
DecimalPipe
|
||||
],
|
||||
templateUrl: './bulk-operations.component.html',
|
||||
styleUrls: ['./bulk-operations.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'app-bulk-operations',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
TranslocoModule,
|
||||
NgbTooltip,
|
||||
NgStyle,
|
||||
DecimalPipe
|
||||
],
|
||||
templateUrl: './bulk-operations.component.html',
|
||||
styleUrls: ['./bulk-operations.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class BulkOperationsComponent implements OnInit {
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
<h4>
|
||||
@if (actions.length > 0) {
|
||||
<span>
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [actions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
<app-card-actionables (actionHandler)="performAction($event)" [inputActions]="actions" [labelBy]="header"></app-card-actionables>
|
||||
</span>
|
||||
}
|
||||
|
||||
|
@ -94,7 +94,7 @@
|
||||
|
||||
<span class="card-actions">
|
||||
@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>
|
||||
</div>
|
||||
|
@ -344,10 +344,6 @@ export class CardItemComponent implements OnInit {
|
||||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
preventClick(event: any) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (action.action == Action.Download) {
|
||||
|
@ -89,7 +89,7 @@
|
||||
</span>
|
||||
<span class="card-actions">
|
||||
@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>
|
||||
</div>
|
||||
|
@ -3,9 +3,11 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
EventEmitter, HostListener,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input, OnInit,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
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 {AccountService} from "../../_services/account.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 {Observable} from "rxjs";
|
||||
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 {Router, RouterLink} from "@angular/router";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {filter, map} from "rxjs/operators";
|
||||
import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update-event";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {MangaFormat} from "../../_models/manga-format";
|
||||
|
||||
@Component({
|
||||
@ -60,15 +59,16 @@ export class ChapterCardComponent implements OnInit {
|
||||
public readonly imageService = inject(ImageService);
|
||||
public readonly bulkSelectionService = inject(BulkSelectionService);
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly actionService = inject(ActionService);
|
||||
private readonly messageHub = inject(MessageHubService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly scrollService = inject(ScrollService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly actionFactoryService = inject(ActionFactoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly readerService = inject(ReaderService);
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
|
||||
@Input({required: true}) libraryId: number = 0;
|
||||
@Input({required: true}) seriesId: number = 0;
|
||||
@Input({required: true}) chapter!: Chapter;
|
||||
@ -143,8 +143,6 @@ export class ChapterCardComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.filterSendTo();
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
@ -172,30 +170,6 @@ export class ChapterCardComponent implements OnInit {
|
||||
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) {
|
||||
if (this.bulkSelectionService.hasSelections()) {
|
||||
this.handleSelection(event);
|
||||
@ -209,8 +183,4 @@ export class ChapterCardComponent implements OnInit {
|
||||
event.stopPropagation();
|
||||
this.readerService.readChapter(this.libraryId, this.seriesId, this.chapter, false);
|
||||
}
|
||||
|
||||
|
||||
protected readonly LibraryType = LibraryType;
|
||||
protected readonly MangaFormat = MangaFormat;
|
||||
}
|
||||
|
@ -32,7 +32,7 @@
|
||||
</span>
|
||||
@if (actions && actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
@ -1,20 +1,20 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, ContentChild,
|
||||
DestroyRef, EventEmitter,
|
||||
Component,
|
||||
ContentChild,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
HostListener,
|
||||
inject,
|
||||
Input, Output, TemplateRef
|
||||
Input,
|
||||
Output,
|
||||
TemplateRef
|
||||
} 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 {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 {AccountService} from "../../_services/account.service";
|
||||
import {ScrollService} from "../../_services/scroll.service";
|
||||
import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component";
|
||||
@ -139,11 +139,6 @@ export class PersonCardComponent {
|
||||
this.clicked.emit(this.title);
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.entity);
|
||||
}
|
||||
}
|
||||
|
||||
handleSelection(event?: any) {
|
||||
if (event) {
|
||||
|
@ -74,7 +74,7 @@
|
||||
|
||||
@if (actions && actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
@ -22,7 +22,6 @@ import {ActionService} from 'src/app/_services/action.service';
|
||||
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
|
||||
import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {CardItemComponent} from "../card-item/card-item.component";
|
||||
import {RelationshipPipe} from "../../_pipes/relationship.pipe";
|
||||
import {Device} from "../../_models/device/device";
|
||||
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 {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {DownloadIndicatorComponent} from "../download-indicator/download-indicator.component";
|
||||
import {EntityTitleComponent} from "../entity-title/entity-title.component";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
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 {AccountService} from "../../_services/account.service";
|
||||
import {BulkSelectionService} from "../bulk-selection.service";
|
||||
import {User} from "../../_models/user";
|
||||
import {ScrollService} from "../../_services/scroll.service";
|
||||
import {ReaderService} from "../../_services/reader.service";
|
||||
import {SeriesFormatComponent} from "../../shared/series-format/series-format.component";
|
||||
@ -147,8 +144,6 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
*/
|
||||
prevOffset: number = 0;
|
||||
selectionInProgress: boolean = false;
|
||||
private user: User | undefined;
|
||||
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
onTouchMove(event: TouchEvent) {
|
||||
@ -192,15 +187,15 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
|
||||
ngOnChanges(changes: any) {
|
||||
if (this.series) {
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
// this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
// this.user = user;
|
||||
// });
|
||||
|
||||
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
|
||||
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) {
|
||||
const othersIndex = this.actions.findIndex(obj => obj.title === 'others');
|
||||
const othersAction = deepClone(this.actions[othersIndex]) as ActionItem<Series>;
|
||||
@ -209,9 +204,11 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
action: Action.RemoveFromOnDeck,
|
||||
title: 'remove-from-on-deck',
|
||||
description: '',
|
||||
callback: (action: ActionItem<Series>, series: Series) => this.handleSeriesActionCallback(action, series),
|
||||
callback: this.handleSeriesActionCallback.bind(this),
|
||||
class: 'danger',
|
||||
requiresAdmin: false,
|
||||
requiredRoles: [],
|
||||
shouldRender: (_, _2, _3) => true,
|
||||
children: [],
|
||||
});
|
||||
this.actions[othersIndex] = othersAction;
|
||||
|
@ -81,7 +81,7 @@
|
||||
|
||||
@if (actions && actions.length > 0) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
|
@ -23,7 +23,7 @@ import {DownloadEvent, DownloadService} from "../../shared/_services/download.se
|
||||
import {EVENTS, MessageHubService} from "../../_services/message-hub.service";
|
||||
import {AccountService} from "../../_services/account.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 {Observable} from "rxjs";
|
||||
import {User} from "../../_models/user";
|
||||
@ -33,7 +33,6 @@ import {UserProgressUpdateEvent} from "../../_models/events/user-progress-update
|
||||
import {Volume} from "../../_models/volume";
|
||||
import {UtilityService} from "../../shared/_services/utility.service";
|
||||
import {LibraryType} from "../../_models/library/library";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {ActionService} from "../../_services/action.service";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
|
||||
@ -143,8 +142,6 @@ export class VolumeCardComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.filterSendTo();
|
||||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => {
|
||||
this.user = user;
|
||||
});
|
||||
@ -180,30 +177,6 @@ export class VolumeCardComponent implements OnInit {
|
||||
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) {
|
||||
if (this.bulkSelectionService.hasSelections()) {
|
||||
this.handleSelection(event);
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="carousel-container mb-3">
|
||||
<div>
|
||||
@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}">
|
||||
@if (titleLink !== '') {
|
||||
|
@ -74,7 +74,7 @@
|
||||
|
||||
<div class="col-auto ms-2k">
|
||||
<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>
|
||||
|
||||
|
@ -83,7 +83,7 @@ enum TabID {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chapter-detail',
|
||||
selector: 'app-chapter-detail',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
CardActionablesComponent,
|
||||
@ -116,9 +116,9 @@ enum TabID {
|
||||
ReviewsComponent,
|
||||
ExternalRatingComponent
|
||||
],
|
||||
templateUrl: './chapter-detail.component.html',
|
||||
styleUrl: './chapter-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
templateUrl: './chapter-detail.component.html',
|
||||
styleUrl: './chapter-detail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChapterDetailComponent implements OnInit {
|
||||
|
||||
@ -339,10 +339,6 @@ export class ChapterDetailComponent implements OnInit {
|
||||
this.location.replaceState(newUrl)
|
||||
}
|
||||
|
||||
openPerson(field: FilterField, value: number) {
|
||||
this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe();
|
||||
}
|
||||
|
||||
downloadChapter() {
|
||||
if (this.downloadInProgress) return;
|
||||
this.downloadService.download('chapter', this.chapter!, (d) => {
|
||||
@ -360,11 +356,6 @@ export class ChapterDetailComponent implements OnInit {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
performAction(action: ActionItem<Chapter>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, this.chapter!);
|
||||
}
|
||||
}
|
||||
|
||||
handleChapterActionCallback(action: ActionItem<Chapter>, chapter: Chapter) {
|
||||
switch (action.action) {
|
||||
|
@ -6,11 +6,9 @@
|
||||
<ng-container title>
|
||||
@if (collectionTag) {
|
||||
<h4>
|
||||
{{collectionTag.title}}
|
||||
@if(collectionTag.promoted) {
|
||||
<span class="ms-1">(<i aria-hidden="true" class="fa fa-angle-double-up"></i>)</span>
|
||||
}
|
||||
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
<app-promoted-icon [promoted]="collectionTag.promoted"></app-promoted-icon>
|
||||
<span class="ms-2">{{collectionTag.title}}</span>
|
||||
<app-card-actionables [entity]="collectionTag" [disabled]="actionInProgress" [inputActions]="collectionTagActions" [labelBy]="collectionTag.title" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
</h4>
|
||||
}
|
||||
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: series.length})}}</h5>
|
||||
|
@ -61,6 +61,7 @@ import {
|
||||
} from "../../../_single-module/smart-collection-drawer/smart-collection-drawer.component";
|
||||
import {DefaultModalOptions} from "../../../_models/default-modal-options";
|
||||
import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
|
||||
import {PromotedIconComponent} from "../../../shared/_components/promoted-icon/promoted-icon.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-collection-detail',
|
||||
@ -69,7 +70,7 @@ import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.p
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SideNavCompanionBarComponent, CardActionablesComponent, ImageComponent, ReadMoreComponent,
|
||||
BulkOperationsComponent, CardDetailLayoutComponent, SeriesCardComponent, TranslocoDirective, NgbTooltip,
|
||||
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe]
|
||||
DatePipe, DefaultDatePipe, ProviderImagePipe, AsyncPipe, ScrobbleProviderNamePipe, PromotedIconComponent]
|
||||
})
|
||||
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) {
|
||||
const modalRef = this.modalService.open(EditCollectionTagsComponent, DefaultModalOptions);
|
||||
modalRef.componentInstance.tag = this.collectionTag;
|
||||
@ -320,7 +315,6 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked {
|
||||
}
|
||||
|
||||
openSyncDetailDrawer() {
|
||||
|
||||
const ref = this.offcanvasService.open(SmartCollectionDrawerComponent, {position: 'end', panelClass: ''});
|
||||
ref.componentInstance.collection = this.collectionTag;
|
||||
ref.componentInstance.series = this.series;
|
||||
|
@ -3,7 +3,7 @@
|
||||
<app-side-nav-companion-bar [hasFilter]="true" (filterOpen)="filterOpen.emit($event)" [filterActive]="filterActive">
|
||||
<h4 title>
|
||||
<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>
|
||||
@if (active.fragment === '') {
|
||||
<h5 subtitle class="subtitle-with-actionables">{{t('common.series-count', {num: pagination.totalItems | number})}} </h5>
|
||||
@ -31,7 +31,6 @@
|
||||
</ng-template>
|
||||
|
||||
<ng-template #noData>
|
||||
<!-- TODO: Come back and figure this out -->
|
||||
{{t('common.no-data')}}
|
||||
</ng-template>
|
||||
</app-card-detail-layout>
|
||||
|
@ -297,8 +297,6 @@ export class LibraryDetailComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
performAction(action: ActionItem<any>) {
|
||||
if (typeof action.callback === 'function') {
|
||||
action.callback(action, undefined);
|
||||
|
@ -15,6 +15,10 @@
|
||||
} @else {
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="resetField()"></button>
|
||||
}
|
||||
} @else {
|
||||
<div class="input-hint">
|
||||
Ctrl+K
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -9,6 +9,17 @@
|
||||
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 {
|
||||
border: 1px solid transparent;
|
||||
|
@ -100,7 +100,9 @@ export class GroupedTypeaheadComponent implements OnInit {
|
||||
|
||||
|
||||
hasFocus: boolean = false;
|
||||
typeaheadForm: FormGroup = new FormGroup({});
|
||||
typeaheadForm: FormGroup = new FormGroup({
|
||||
typeahead: new FormControl('', []),
|
||||
});
|
||||
includeChapterAndFiles: boolean = false;
|
||||
prevSearchTerm: string = '';
|
||||
searchSettingsForm = new FormGroup(({'includeExtras': new FormControl(false)}));
|
||||
@ -121,22 +123,37 @@ export class GroupedTypeaheadComponent implements OnInit {
|
||||
this.close();
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeyPress(event: KeyboardEvent) {
|
||||
if (!this.hasFocus) { return; }
|
||||
|
||||
const isCtrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
|
||||
|
||||
switch(event.key) {
|
||||
case KEY_CODES.ESC_KEY:
|
||||
if (!this.hasFocus) { return; }
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
break;
|
||||
|
||||
case KEY_CODES.K:
|
||||
if (isCtrlOrMeta) {
|
||||
if (this.inputElem.nativeElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.inputElem.nativeElement.focus();
|
||||
this.inputElem.nativeElement.click();
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.typeaheadForm.addControl('typeahead', new FormControl(this.initialValue, []));
|
||||
this.typeaheadForm.get('typeahead')?.setValue(this.initialValue);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.searchSettingsForm.get('includeExtras')!.valueChanges.pipe(
|
||||
|
@ -5,7 +5,7 @@
|
||||
<app-side-nav-companion-bar>
|
||||
<ng-container title>
|
||||
<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>
|
||||
|
||||
@if (person.aniListId) {
|
||||
|
@ -19,7 +19,6 @@ import {
|
||||
SideNavCompanionBarComponent
|
||||
} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.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 {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {FilterComparison} from "../_models/metadata/v2/filter-comparison";
|
||||
@ -89,7 +88,7 @@ export class PersonDetailComponent implements OnInit {
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly messageHubService = inject(MessageHubService)
|
||||
|
||||
protected readonly TagBadgeCursor = TagBadgeCursor;
|
||||
protected readonly FilterField = FilterField;
|
||||
|
||||
@ViewChild('scrollingBlock') scrollingBlock: 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;
|
||||
}
|
||||
|
@ -10,12 +10,10 @@
|
||||
|
||||
<div class="col-xl-10 col-lg-7 col-md-12 col-sm-12 col-xs-12">
|
||||
<h4 class="title mb-2">
|
||||
<span>{{readingList.title}}
|
||||
@if (readingList.promoted) {
|
||||
(<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>)
|
||||
}
|
||||
|
||||
@if( isLoading) {
|
||||
<span>
|
||||
<app-promoted-icon [promoted]="readingList.promoted"></app-promoted-icon>
|
||||
<span class="ms-2">{{readingList.title}}</span>
|
||||
@if(isLoading) {
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">loading...</span>
|
||||
</div>
|
||||
@ -87,7 +85,7 @@
|
||||
|
||||
<div class="col-auto ms-2 d-none d-md-block">
|
||||
<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>
|
||||
|
||||
|
@ -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) {
|
||||
if (!this.readingList) return;
|
||||
@ -387,12 +382,6 @@ export class ReadingListDetailComponent implements OnInit {
|
||||
{queryParams: {readingListId: this.readingList.id, incognitoMode: incognitoMode}});
|
||||
}
|
||||
|
||||
updateAccessibilityMode() {
|
||||
this.accessibilityMode = !this.accessibilityMode;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
|
||||
toggleReorder() {
|
||||
this.formGroup.get('edit')?.setValue(!this.formGroup.get('edit')!.value);
|
||||
this.cdRef.markForCheck();
|
||||
|
@ -3,7 +3,7 @@
|
||||
<app-side-nav-companion-bar>
|
||||
<h4 title>
|
||||
<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>
|
||||
@if (pagination) {
|
||||
<h5 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h5>
|
||||
|
@ -96,7 +96,7 @@
|
||||
|
||||
<div class="col-auto ms-2">
|
||||
<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>
|
||||
|
||||
|
@ -61,11 +61,6 @@ import {ReaderService} from 'src/app/_services/reader.service';
|
||||
import {ReadingListService} from 'src/app/_services/reading-list.service';
|
||||
import {ScrollService} from 'src/app/_services/scroll.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 {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
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 {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller';
|
||||
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 {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
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() {
|
||||
this.downloadService.download('series', this.series, (d) => {
|
||||
this.downloadInProgress = !!d;
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
import { LibraryType } from 'src/app/_models/library/library';
|
||||
import { MangaFormat } from 'src/app/_models/manga-format';
|
||||
import { PaginatedResult } from 'src/app/_models/pagination';
|
||||
import { Series } from 'src/app/_models/series';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import {translate, TranslocoService} from "@jsverse/transloco";
|
||||
import {HttpParams} from '@angular/common/http';
|
||||
import {Injectable} from '@angular/core';
|
||||
import {Chapter} from 'src/app/_models/chapter';
|
||||
import {LibraryType} from 'src/app/_models/library/library';
|
||||
import {MangaFormat} from 'src/app/_models/manga-format';
|
||||
import {PaginatedResult} from 'src/app/_models/pagination';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {Volume} from 'src/app/_models/volume';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {debounceTime, ReplaySubject, shareReplay} from "rxjs";
|
||||
|
||||
export enum KEY_CODES {
|
||||
@ -21,6 +21,7 @@ export enum KEY_CODES {
|
||||
B = 'b',
|
||||
F = 'f',
|
||||
H = 'h',
|
||||
K = 'k',
|
||||
BACKSPACE = 'Backspace',
|
||||
DELETE = 'Delete',
|
||||
SHIFT = 'Shift'
|
||||
@ -41,6 +42,9 @@ export class UtilityService {
|
||||
public readonly activeBreakpointSource = new ReplaySubject<Breakpoint>(1);
|
||||
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[] = [];
|
||||
|
||||
|
||||
|
@ -8,8 +8,7 @@
|
||||
|
||||
<app-side-nav-item cdkDrag cdkDragDisabled icon="fa-home" [title]="t('home')" link="/home/">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v"
|
||||
(actionHandler)="performHomeAction($event)" />
|
||||
<app-card-actionables [inputActions]="homeActions" labelBy="home" iconClass="fa-ellipsis-v" (actionHandler)="performHomeAction($event)" />
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
|
||||
@ -44,8 +43,7 @@
|
||||
[imageUrl]="getLibraryImage(navStream.library!)" [title]="navStream.library!.name"
|
||||
[comparisonMethod]="'startsWith'">
|
||||
<ng-container actions>
|
||||
<app-card-actionables [actions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"
|
||||
(actionHandler)="performAction($event, navStream.library!)"></app-card-actionables>
|
||||
<app-card-actionables [entity]="navStream.library" [inputActions]="actions" [labelBy]="navStream.name" iconClass="fa-ellipsis-v"></app-card-actionables>
|
||||
</ng-container>
|
||||
</app-side-nav-item>
|
||||
}
|
||||
|
@ -155,24 +155,25 @@ export class SideNavComponent implements OnInit {
|
||||
}
|
||||
|
||||
async handleAction(action: ActionItem<Library>, library: Library) {
|
||||
const lib = library;
|
||||
switch (action.action) {
|
||||
case(Action.Scan):
|
||||
await this.actionService.scanLibrary(library);
|
||||
await this.actionService.scanLibrary(lib);
|
||||
break;
|
||||
case(Action.RefreshMetadata):
|
||||
await this.actionService.refreshLibraryMetadata(library);
|
||||
await this.actionService.refreshLibraryMetadata(lib);
|
||||
break;
|
||||
case(Action.GenerateColorScape):
|
||||
await this.actionService.refreshLibraryMetadata(library, undefined, false);
|
||||
await this.actionService.refreshLibraryMetadata(lib, undefined, false);
|
||||
break;
|
||||
case (Action.AnalyzeFiles):
|
||||
await this.actionService.analyzeFiles(library);
|
||||
await this.actionService.analyzeFiles(lib);
|
||||
break;
|
||||
case (Action.Delete):
|
||||
await this.actionService.deleteLibrary(library);
|
||||
await this.actionService.deleteLibrary(lib);
|
||||
break;
|
||||
case (Action.Edit):
|
||||
this.actionService.editLibrary(library, () => window.scrollTo(0, 0));
|
||||
this.actionService.editLibrary(lib, () => window.scrollTo(0, 0));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@ -191,6 +192,7 @@ export class SideNavComponent implements OnInit {
|
||||
|
||||
performAction(action: ActionItem<Library>, library: Library) {
|
||||
if (typeof action.callback === 'function') {
|
||||
console.log('library: ', library)
|
||||
action.callback(action, library);
|
||||
}
|
||||
}
|
||||
|
@ -257,12 +257,12 @@ export class LibrarySettingsModalComponent implements OnInit {
|
||||
|
||||
// TODO: Refactor into FormArray
|
||||
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
|
||||
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;
|
||||
|
@ -77,7 +77,7 @@
|
||||
|
||||
<div class="col-auto ms-2">
|
||||
<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>
|
||||
|
||||
|
@ -80,6 +80,7 @@ import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {ReviewsComponent} from "../_single-module/reviews/reviews.component";
|
||||
import {ExternalRatingComponent} from "../series-detail/_components/external-rating/external-rating.component";
|
||||
import {ChapterService} from "../_services/chapter.service";
|
||||
import {User} from "../_models/user";
|
||||
|
||||
enum TabID {
|
||||
|
||||
@ -187,6 +188,7 @@ export class VolumeDetailComponent implements OnInit {
|
||||
protected readonly TabID = TabID;
|
||||
protected readonly FilterField = FilterField;
|
||||
protected readonly Breakpoint = Breakpoint;
|
||||
protected readonly encodeURIComponent = encodeURIComponent;
|
||||
|
||||
@ViewChild('scrollingBlock') scrollingBlock: ElementRef<HTMLDivElement> | undefined;
|
||||
@ViewChild('companionBar') companionBar: ElementRef<HTMLDivElement> | undefined;
|
||||
@ -211,7 +213,7 @@ export class VolumeDetailComponent implements OnInit {
|
||||
mobileSeriesImgBackground: string | undefined;
|
||||
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));
|
||||
|
||||
bulkActionCallback = async (action: ActionItem<Chapter>, _: any) => {
|
||||
@ -570,16 +572,6 @@ export class VolumeDetailComponent implements OnInit {
|
||||
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) {
|
||||
switch (action.action) {
|
||||
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>) {
|
||||
switch (action.action) {
|
||||
case Action.Delete:
|
||||
@ -687,6 +690,4 @@ export class VolumeDetailComponent implements OnInit {
|
||||
this.currentlyReadingChapter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly encodeURIComponent = encodeURIComponent;
|
||||
}
|
||||
|
@ -439,4 +439,8 @@
|
||||
|
||||
/** Series Detail **/
|
||||
--detail-subtitle-color: lightgrey;
|
||||
|
||||
/** Search **/
|
||||
--input-hint-border-color: #aeaeae;
|
||||
--input-hint-text-color: lightgrey;
|
||||
}
|
||||
|
4
build.sh
4
build.sh
@ -94,9 +94,11 @@ Package()
|
||||
fi
|
||||
|
||||
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"
|
||||
rm $lOutputFolder/config/appsettings.Development.json
|
||||
echo "Removing appsettings.json"
|
||||
rm $lOutputFolder/config/appsettings.json
|
||||
|
||||
echo "Creating tar"
|
||||
cd ../$outputFolder/"$runtime"/
|
||||
|
Loading…
x
Reference in New Issue
Block a user