Random Stuff (#3798)

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

View File

@ -9,10 +9,10 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
<PackageReference Include="Microsoft.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>

View File

@ -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>

View File

@ -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; }
}

View File

@ -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;

View File

@ -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;

View File

@ -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)

View File

@ -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;

View File

@ -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]));
}

View File

@ -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;

View File

@ -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();
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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) {

View File

@ -1,20 +1,19 @@
import { HttpClient } from '@angular/common/http';
import {HttpClient, HttpParams} from '@angular/common/http';
import {Inject, inject, Injectable} from '@angular/core';
import { 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() {

View File

@ -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"

View File

@ -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;

View File

@ -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>

View File

@ -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;
//}

View File

@ -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);

View File

@ -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>

View File

@ -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();

View File

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

View File

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

View File

@ -10,6 +10,4 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
export class UpdateSectionComponent {
@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
}

View File

@ -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>

View File

@ -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 {

View File

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

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -22,7 +22,6 @@ import {ActionService} from 'src/app/_services/action.service';
import {EditSeriesModalComponent} from '../_modals/edit-series-modal/edit-series-modal.component';
import {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;

View File

@ -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>

View File

@ -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);

View File

@ -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 !== '') {

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -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;

View File

@ -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(

View File

@ -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) {

View File

@ -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;
}

View File

@ -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>

View File

@ -273,11 +273,6 @@ export class ReadingListDetailComponent implements OnInit {
});
}
performAction(action: ActionItem<any>) {
if (typeof action.callback === 'function') {
action.callback(action, this.readingList);
}
}
readChapter(item: ReadingListItem) {
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();

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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[] = [];

View File

@ -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>
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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>

View File

@ -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;
}

View File

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

View File

@ -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"/