mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-07-09 03:04:19 -04:00
Update Notifier (#464)
# Added - Added: Ability to check for updates (stable-only) and be notified with a changelog. This is a first pass implementation. - Added: Ability to use SignalR within Kavita (websockets) ===================================== * (some debug code present). Implemented the ability to check and log if the server is up to date or not. * Fixed a bug for dark mode where anchor buttons wouldn't have the correct font color. Suppress filter/sort button if there is no filters to show. Debug: Active indicators for users currently on your server. Refactored code to send update notification only to admins. Admins now get a popup where they can open the Github release (docker users can just close). * Fixed an issue where getLibraryNames on first load would call for as many cards there was on the screen. Now we call it much earlier and the data is cached faster. * Fixed a dark mode bug from previous commit * Release notes is now rendered markdown * Implemented the ability to check for an update ad-hoc. Response will come via websocket to all admins. * Fixed a missing padding * Cleanup, added some temp code to carousel * Cleaned up old stat stuff from dev config and added debug only flow for checking for update * Misc cleanup * Added readonly to one variable * Fixed In Progress not showing for all series due to pagination bug * Fixed the In progress API returning back series that had another users progress on them. Added SplitQuery which speeds up query significantly. * SplitQuery in GetRecentlyAdded for a speed increase on API. Fixed the logic on VersionUpdaterService to properly send on non-dev systems. Disable the check button once it's triggered once since the API does a task, so it can't return anything. * Cleaned up the admin actions to be more friendly on mobile. * Cleaned up the message as we wait for SingalR to notify the user * more textual changes * Code smells
This commit is contained in:
parent
867b8e5c8a
commit
2809233de0
@ -33,14 +33,18 @@
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
|
||||
<PackageReference Include="Docnet.Core" Version="2.3.1" />
|
||||
<PackageReference Include="ExCSS" Version="4.1.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.2" />
|
||||
<PackageReference Include="Flurl.Http" Version="3.2.0" />
|
||||
<PackageReference Include="Hangfire" Version="1.7.20" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.20" />
|
||||
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
|
||||
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.32" />
|
||||
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
@ -168,7 +168,7 @@ namespace API.Controllers
|
||||
[HttpPost("in-progress")]
|
||||
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
|
||||
{
|
||||
// NOTE: This has to be done manually like this due to the DisinctBy requirement
|
||||
// NOTE: This has to be done manually like this due to the DistinctBy requirement
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
|
||||
var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto);
|
||||
|
||||
|
@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Stats;
|
||||
using API.Extensions;
|
||||
using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services.Tasks;
|
||||
using Kavita.Common;
|
||||
@ -23,9 +24,10 @@ namespace API.Controllers
|
||||
private readonly IBackupService _backupService;
|
||||
private readonly IArchiveService _archiveService;
|
||||
private readonly ICacheService _cacheService;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
|
||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
||||
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService)
|
||||
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, ITaskScheduler taskScheduler)
|
||||
{
|
||||
_applicationLifetime = applicationLifetime;
|
||||
_logger = logger;
|
||||
@ -33,6 +35,7 @@ namespace API.Controllers
|
||||
_backupService = backupService;
|
||||
_archiveService = archiveService;
|
||||
_cacheService = cacheService;
|
||||
_taskScheduler = taskScheduler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -99,7 +102,11 @@ namespace API.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpPost("check-update")]
|
||||
public ActionResult CheckForUpdates()
|
||||
{
|
||||
_taskScheduler.CheckForUpdate();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,12 +29,11 @@ namespace API.Controllers
|
||||
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception e)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(e, "Error updating the usage statistics");
|
||||
Console.WriteLine(e);
|
||||
_logger.LogError(ex, "Error updating the usage statistics");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -334,6 +334,7 @@ namespace API.Data
|
||||
.Where(s => s.LibraryId == libraryId && formats.Contains(s.Format))
|
||||
.OrderByDescending(s => s.Created)
|
||||
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking();
|
||||
|
||||
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
|
||||
|
@ -4,6 +4,7 @@ using API.Interfaces;
|
||||
using API.Interfaces.Services;
|
||||
using API.Services;
|
||||
using API.Services.Tasks;
|
||||
using API.SignalR.Presence;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@ -32,9 +33,13 @@ namespace API.Extensions
|
||||
services.AddScoped<ICleanupService, CleanupService>();
|
||||
services.AddScoped<IBookService, BookService>();
|
||||
services.AddScoped<IImageService, ImageService>();
|
||||
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
|
||||
|
||||
services.AddScoped<IPresenceTracker, PresenceTracker>();
|
||||
|
||||
services.AddSqLite(config, env);
|
||||
services.AddLogging(config);
|
||||
services.AddSignalR();
|
||||
}
|
||||
|
||||
private static void AddSqLite(this IServiceCollection services, IConfiguration config,
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
@ -24,7 +25,7 @@ namespace API.Extensions
|
||||
.AddSignInManager<SignInManager<AppUser>>()
|
||||
.AddRoleValidator<RoleValidator<AppRole>>()
|
||||
.AddEntityFrameworkStores<DataContext>();
|
||||
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
@ -35,14 +36,30 @@ namespace API.Extensions
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents()
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
// Only use query string based token on SignalR hubs
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
services.AddAuthorization(opt =>
|
||||
{
|
||||
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole));
|
||||
opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
|
||||
});
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,13 +6,15 @@
|
||||
/// For use on Server startup
|
||||
/// </summary>
|
||||
void ScheduleTasks();
|
||||
void ScheduleStatsTasks();
|
||||
void ScheduleUpdaterTasks();
|
||||
void ScanLibrary(int libraryId, bool forceUpdate = false);
|
||||
void CleanupChapters(int[] chapterIds);
|
||||
void RefreshMetadata(int libraryId, bool forceUpdate = true);
|
||||
void CleanupTemp();
|
||||
void RefreshSeriesMetadata(int libraryId, int seriesId);
|
||||
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
|
||||
void ScheduleStatsTasks();
|
||||
void CancelStatsTasks();
|
||||
void CheckForUpdate();
|
||||
}
|
||||
}
|
||||
|
11
API/Interfaces/Services/IVersionUpdaterService.cs
Normal file
11
API/Interfaces/Services/IVersionUpdaterService.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace API.Interfaces.Services
|
||||
{
|
||||
public interface IVersionUpdaterService
|
||||
{
|
||||
public Task CheckForUpdate();
|
||||
|
||||
}
|
||||
}
|
@ -39,16 +39,12 @@ namespace API.Services.Clients
|
||||
response = responseContent
|
||||
};
|
||||
|
||||
_logger.LogError(e, "The StatsServer did not respond successfully. {Content}", info);
|
||||
|
||||
Console.WriteLine(e);
|
||||
_logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info);
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to the Stats Server");
|
||||
|
||||
Console.WriteLine(e);
|
||||
_logger.LogError(e, "An error happened during the request to KavitaStats");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ namespace API.Services.HostedServices
|
||||
|
||||
var taskScheduler = scope.ServiceProvider.GetRequiredService<ITaskScheduler>();
|
||||
taskScheduler.ScheduleTasks();
|
||||
taskScheduler.ScheduleUpdaterTasks();
|
||||
|
||||
try
|
||||
{
|
||||
@ -51,4 +52,4 @@ namespace API.Services.HostedServices
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,13 +21,14 @@ namespace API.Services
|
||||
private readonly ICleanupService _cleanupService;
|
||||
|
||||
private readonly IStatsService _statsService;
|
||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||
|
||||
public static BackgroundJobServer Client => new BackgroundJobServer();
|
||||
|
||||
|
||||
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
|
||||
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
|
||||
ICleanupService cleanupService, IStatsService statsService)
|
||||
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_logger = logger;
|
||||
@ -37,6 +38,7 @@ namespace API.Services
|
||||
_backupService = backupService;
|
||||
_cleanupService = cleanupService;
|
||||
_statsService = statsService;
|
||||
_versionUpdaterService = versionUpdaterService;
|
||||
}
|
||||
|
||||
public void ScheduleTasks()
|
||||
@ -97,6 +99,16 @@ namespace API.Services
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateTasks
|
||||
|
||||
public void ScheduleUpdaterTasks()
|
||||
{
|
||||
_logger.LogInformation("Scheduling Auto-Update tasks");
|
||||
RecurringJob.AddOrUpdate("check-updates", () => _versionUpdaterService.CheckForUpdate(), Cron.Daily);
|
||||
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void ScanLibrary(int libraryId, bool forceUpdate = false)
|
||||
{
|
||||
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
|
||||
@ -138,5 +150,10 @@ namespace API.Services
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _backupService.BackupDatabase());
|
||||
}
|
||||
|
||||
public void CheckForUpdate()
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _versionUpdaterService.CheckForUpdate());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
112
API/Services/Tasks/VersionUpdaterService.cs
Normal file
112
API/Services/Tasks/VersionUpdaterService.cs
Normal file
@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces.Services;
|
||||
using API.SignalR;
|
||||
using API.SignalR.Presence;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using MarkdownDeep;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Tasks
|
||||
{
|
||||
internal class GithubReleaseMetadata
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the Tag
|
||||
/// <example>v0.4.3</example>
|
||||
/// </summary>
|
||||
public string Tag_Name { get; init; }
|
||||
/// <summary>
|
||||
/// Name of the Release
|
||||
/// </summary>
|
||||
public string Name { get; init; }
|
||||
/// <summary>
|
||||
/// Body of the Release
|
||||
/// </summary>
|
||||
public string Body { get; init; }
|
||||
/// <summary>
|
||||
/// Url of the release on Github
|
||||
/// </summary>
|
||||
public string Html_Url { get; init; }
|
||||
|
||||
}
|
||||
public class VersionUpdaterService : IVersionUpdaterService
|
||||
{
|
||||
private readonly ILogger<VersionUpdaterService> _logger;
|
||||
private readonly IHubContext<MessageHub> _messageHub;
|
||||
private readonly IPresenceTracker _tracker;
|
||||
private readonly Markdown _markdown = new MarkdownDeep.Markdown();
|
||||
|
||||
public VersionUpdaterService(ILogger<VersionUpdaterService> logger, IHubContext<MessageHub> messageHub, IPresenceTracker tracker)
|
||||
{
|
||||
_logger = logger;
|
||||
_messageHub = messageHub;
|
||||
_tracker = tracker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled Task that checks if a newer version is available. If it is, will check if User is currently connected and push
|
||||
/// a message.
|
||||
/// </summary>
|
||||
public async Task CheckForUpdate()
|
||||
{
|
||||
|
||||
var update = await GetGithubRelease();
|
||||
|
||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return;
|
||||
|
||||
var admins = await _tracker.GetOnlineAdmins();
|
||||
var version = update.Tag_Name.Replace("v", string.Empty);
|
||||
var updateVersion = new Version(version);
|
||||
if (BuildInfo.Version < updateVersion)
|
||||
{
|
||||
_logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion);
|
||||
await SendEvent(update, admins);
|
||||
}
|
||||
else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development)
|
||||
{
|
||||
_logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version);
|
||||
await SendEvent(update, admins);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendEvent(GithubReleaseMetadata update, IReadOnlyList<string> admins)
|
||||
{
|
||||
var version = update.Tag_Name.Replace("v", string.Empty);
|
||||
var updateVersion = new Version(version);
|
||||
var connections = new List<string>();
|
||||
foreach (var admin in admins)
|
||||
{
|
||||
connections.AddRange(await _tracker.GetConnectionsForUser(admin));
|
||||
}
|
||||
|
||||
await _messageHub.Clients.Users(admins).SendAsync("UpdateAvailable", new SignalRMessage
|
||||
{
|
||||
Name = "UpdateAvailable",
|
||||
Body = new
|
||||
{
|
||||
CurrentVersion = version,
|
||||
UpdateVersion = updateVersion.ToString(),
|
||||
UpdateBody = _markdown.Transform(update.Body.Trim()),
|
||||
UpdateTitle = update.Name,
|
||||
UpdateUrl = update.Html_Url,
|
||||
IsDocker = new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<GithubReleaseMetadata> GetGithubRelease()
|
||||
{
|
||||
var update = await "https://api.github.com/repos/Kareadita/Kavita/releases/latest"
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.GetJsonAsync<GithubReleaseMetadata>();
|
||||
|
||||
return update;
|
||||
}
|
||||
}
|
||||
}
|
46
API/SignalR/MessageHub.cs
Normal file
46
API/SignalR/MessageHub.cs
Normal file
@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace API.SignalR
|
||||
{
|
||||
|
||||
[Authorize]
|
||||
public class MessageHub : Hub
|
||||
{
|
||||
private static readonly HashSet<string> _connections = new HashSet<string>();
|
||||
|
||||
public static bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
return _connections.Count != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
_connections.Add(Context.ConnectionId);
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception exception)
|
||||
{
|
||||
lock (_connections)
|
||||
{
|
||||
_connections.Remove(Context.ConnectionId);
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
}
|
99
API/SignalR/Presence/PresenceTracker.cs
Normal file
99
API/SignalR/Presence/PresenceTracker.cs
Normal file
@ -0,0 +1,99 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Interfaces;
|
||||
|
||||
namespace API.SignalR.Presence
|
||||
{
|
||||
public interface IPresenceTracker
|
||||
{
|
||||
Task UserConnected(string username, string connectionId);
|
||||
Task UserDisconnected(string username, string connectionId);
|
||||
Task<string[]> GetOnlineAdmins();
|
||||
Task<List<string>> GetConnectionsForUser(string username);
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is a singleton service for tracking what users have a SignalR connection and their difference connectionIds
|
||||
/// </summary>
|
||||
public class PresenceTracker : IPresenceTracker
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private static readonly Dictionary<string, List<string>> OnlineUsers = new Dictionary<string, List<string>>();
|
||||
|
||||
public PresenceTracker(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public Task UserConnected(string username, string connectionId)
|
||||
{
|
||||
lock (OnlineUsers)
|
||||
{
|
||||
if (OnlineUsers.ContainsKey(username))
|
||||
{
|
||||
OnlineUsers[username].Add(connectionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
OnlineUsers.Add(username, new List<string>() { connectionId });
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UserDisconnected(string username, string connectionId)
|
||||
{
|
||||
lock (OnlineUsers)
|
||||
{
|
||||
if (!OnlineUsers.ContainsKey(username)) return Task.CompletedTask;
|
||||
|
||||
OnlineUsers[username].Remove(connectionId);
|
||||
|
||||
if (OnlineUsers[username].Count == 0)
|
||||
{
|
||||
OnlineUsers.Remove(username);
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public static Task<string[]> GetOnlineUsers()
|
||||
{
|
||||
string[] onlineUsers;
|
||||
lock (OnlineUsers)
|
||||
{
|
||||
onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray();
|
||||
}
|
||||
|
||||
return Task.FromResult(onlineUsers);
|
||||
}
|
||||
|
||||
public async Task<string[]> GetOnlineAdmins()
|
||||
{
|
||||
string[] onlineUsers;
|
||||
lock (OnlineUsers)
|
||||
{
|
||||
onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray();
|
||||
}
|
||||
|
||||
var admins = await _unitOfWork.UserRepository.GetAdminUsersAsync();
|
||||
var result = admins.Select(a => a.UserName).Intersect(onlineUsers).ToArray();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<List<string>> GetConnectionsForUser(string username)
|
||||
{
|
||||
List<string> connectionIds;
|
||||
lock (OnlineUsers)
|
||||
{
|
||||
connectionIds = OnlineUsers.GetValueOrDefault(username);
|
||||
}
|
||||
|
||||
return Task.FromResult(connectionIds);
|
||||
}
|
||||
}
|
||||
}
|
38
API/SignalR/PresenceHub.cs
Normal file
38
API/SignalR/PresenceHub.cs
Normal file
@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Extensions;
|
||||
using API.SignalR.Presence;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace API.SignalR
|
||||
{
|
||||
public class PresenceHub : Hub
|
||||
{
|
||||
private readonly IPresenceTracker _tracker;
|
||||
|
||||
public PresenceHub(IPresenceTracker tracker)
|
||||
{
|
||||
_tracker = tracker;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId);
|
||||
|
||||
var currentUsers = await PresenceTracker.GetOnlineUsers();
|
||||
await Clients.All.SendAsync("GetOnlineUsers", currentUsers);
|
||||
|
||||
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception exception)
|
||||
{
|
||||
await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId);
|
||||
|
||||
var currentUsers = await PresenceTracker.GetOnlineUsers();
|
||||
await Clients.All.SendAsync("GetOnlineUsers", currentUsers);
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
}
|
||||
}
|
11
API/SignalR/SignalRMessage.cs
Normal file
11
API/SignalR/SignalRMessage.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace API.SignalR
|
||||
{
|
||||
public class SignalRMessage
|
||||
{
|
||||
public object Body { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
//[JsonIgnore]
|
||||
//public ModelAction Action { get; set; } // This will be for when we add new flows
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ using API.Extensions;
|
||||
using API.Middleware;
|
||||
using API.Services;
|
||||
using API.Services.HostedServices;
|
||||
using API.SignalR;
|
||||
using Hangfire;
|
||||
using Hangfire.MemoryStorage;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
@ -104,6 +105,7 @@ namespace API
|
||||
app.UseCors(policy => policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials() // For SignalR token query param
|
||||
.WithOrigins("http://localhost:4200")
|
||||
.WithExposedHeaders("Content-Disposition", "Pagination"));
|
||||
}
|
||||
@ -138,6 +140,8 @@ namespace API
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapControllers();
|
||||
endpoints.MapHub<MessageHub>("hubs/messages");
|
||||
endpoints.MapHub<PresenceHub>("hubs/presence");
|
||||
endpoints.MapHangfireDashboard();
|
||||
endpoints.MapFallbackToController("Index", "Fallback");
|
||||
});
|
||||
|
@ -3,11 +3,6 @@
|
||||
"DefaultConnection": "Data source=kavita.db"
|
||||
},
|
||||
"TokenKey": "super secret unguessable key",
|
||||
"StatsOptions": {
|
||||
"ServerUrl": "http://localhost:5002",
|
||||
"ServerSecret": "here's where the api key goes",
|
||||
"SendDataAt": "23:50"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
|
@ -19,4 +19,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
136
UI/Web/package-lock.json
generated
136
UI/Web/package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"@angular/platform-browser-dynamic": "~11.0.0",
|
||||
"@angular/router": "~11.0.0",
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@microsoft/signalr": "^5.0.8",
|
||||
"@ng-bootstrap/ng-bootstrap": "^9.1.0",
|
||||
"@ngx-lite/nav-drawer": "^0.4.6",
|
||||
"@ngx-lite/util": "0.0.0",
|
||||
@ -2762,6 +2763,18 @@
|
||||
"schema-utils": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-5.0.8.tgz",
|
||||
"integrity": "sha512-g5U7zGa1CeoPztA1VGLiB418sZ6gt8ZEOsX8krpegyMquzH2Qinny1zQjNsg3mgjGlJI+FXD5bO4gVsHGUp2hA==",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^1.0.7",
|
||||
"fetch-cookie": "^0.7.3",
|
||||
"node-fetch": "^2.6.0",
|
||||
"ws": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ng-bootstrap/ng-bootstrap": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-9.1.0.tgz",
|
||||
@ -3710,6 +3723,17 @@
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
|
||||
@ -4146,8 +4170,7 @@
|
||||
"node_modules/async-limiter": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
@ -6947,6 +6970,11 @@
|
||||
"next-tick": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-denodeify": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/es6-denodeify/-/es6-denodeify-0.1.5.tgz",
|
||||
"integrity": "sha1-MdTV/pxVA+ElRgQ5MQ4WoqPznB8="
|
||||
},
|
||||
"node_modules/es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
@ -7111,6 +7139,14 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@ -7130,7 +7166,6 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.0.7.tgz",
|
||||
"integrity": "sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"original": "^1.0.0"
|
||||
},
|
||||
@ -7568,6 +7603,15 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-cookie": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.7.3.tgz",
|
||||
"integrity": "sha512-rZPkLnI8x5V+zYAiz8QonAHsTb4BY+iFowFBI1RFn0zrO343AVp9X7/yUj/9wL6Ef/8fLls8b/vGtzUvmyAUGA==",
|
||||
"dependencies": {
|
||||
"es6-denodeify": "^0.1.1",
|
||||
"tough-cookie": "^2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/figgy-pudding": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
|
||||
@ -12730,6 +12774,14 @@
|
||||
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
|
||||
@ -13434,7 +13486,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
|
||||
"integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"url-parse": "^1.4.3"
|
||||
}
|
||||
@ -15188,8 +15239,7 @@
|
||||
"node_modules/psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
|
||||
},
|
||||
"node_modules/public-encrypt": {
|
||||
"version": "4.0.3",
|
||||
@ -15246,7 +15296,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@ -15291,8 +15340,7 @@
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
@ -15764,8 +15812,7 @@
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.19.0",
|
||||
@ -17973,7 +18020,6 @@
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
@ -18462,7 +18508,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
|
||||
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
@ -20086,7 +20131,6 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
|
||||
"integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
@ -22606,6 +22650,18 @@
|
||||
"schema-utils": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"@microsoft/signalr": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-5.0.8.tgz",
|
||||
"integrity": "sha512-g5U7zGa1CeoPztA1VGLiB418sZ6gt8ZEOsX8krpegyMquzH2Qinny1zQjNsg3mgjGlJI+FXD5bO4gVsHGUp2hA==",
|
||||
"requires": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^1.0.7",
|
||||
"fetch-cookie": "^0.7.3",
|
||||
"node-fetch": "^2.6.0",
|
||||
"ws": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"@ng-bootstrap/ng-bootstrap": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-9.1.0.tgz",
|
||||
@ -23454,6 +23510,14 @@
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"requires": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"accepts": {
|
||||
"version": "1.3.7",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
|
||||
@ -23812,8 +23876,7 @@
|
||||
"async-limiter": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
|
||||
},
|
||||
"asynckit": {
|
||||
"version": "0.4.0",
|
||||
@ -26209,6 +26272,11 @@
|
||||
"next-tick": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"es6-denodeify": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/es6-denodeify/-/es6-denodeify-0.1.5.tgz",
|
||||
"integrity": "sha1-MdTV/pxVA+ElRgQ5MQ4WoqPznB8="
|
||||
},
|
||||
"es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
@ -26334,6 +26402,11 @@
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||
"dev": true
|
||||
},
|
||||
"event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@ -26350,7 +26423,6 @@
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.0.7.tgz",
|
||||
"integrity": "sha512-4Ln17+vVT0k8aWq+t/bF5arcS3EpT9gYtW66EPacdj/mAFevznsnyoHLPy2BA8gbIQeIHoPsvwmfBftfcG//BQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"original": "^1.0.0"
|
||||
}
|
||||
@ -26734,6 +26806,15 @@
|
||||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"fetch-cookie": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.7.3.tgz",
|
||||
"integrity": "sha512-rZPkLnI8x5V+zYAiz8QonAHsTb4BY+iFowFBI1RFn0zrO343AVp9X7/yUj/9wL6Ef/8fLls8b/vGtzUvmyAUGA==",
|
||||
"requires": {
|
||||
"es6-denodeify": "^0.1.1",
|
||||
"tough-cookie": "^2.3.3"
|
||||
}
|
||||
},
|
||||
"figgy-pudding": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz",
|
||||
@ -30910,6 +30991,11 @@
|
||||
"integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
|
||||
},
|
||||
"node-forge": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
|
||||
@ -31478,7 +31564,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/original/-/original-1.0.2.tgz",
|
||||
"integrity": "sha512-hyBVl6iqqUOJ8FqRe+l/gS8H+kKYjrEndd5Pm1MfBtsEKA038HkkdbAl/72EAXGyonD/PFsvmVG+EvcIpliMBg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"url-parse": "^1.4.3"
|
||||
}
|
||||
@ -32939,8 +33024,7 @@
|
||||
"psl": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ=="
|
||||
},
|
||||
"public-encrypt": {
|
||||
"version": "4.0.3",
|
||||
@ -33000,8 +33084,7 @@
|
||||
"punycode": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
|
||||
},
|
||||
"q": {
|
||||
"version": "1.5.1",
|
||||
@ -33030,8 +33113,7 @@
|
||||
"querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
||||
},
|
||||
"randombytes": {
|
||||
"version": "2.1.0",
|
||||
@ -33430,8 +33512,7 @@
|
||||
"requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=",
|
||||
"dev": true
|
||||
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.19.0",
|
||||
@ -35285,7 +35366,6 @@
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
|
||||
"integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"psl": "^1.1.28",
|
||||
"punycode": "^2.1.1"
|
||||
@ -35688,7 +35768,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.1.tgz",
|
||||
"integrity": "sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
@ -37058,7 +37137,6 @@
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
|
||||
"integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
|
@ -26,6 +26,7 @@
|
||||
"@angular/platform-browser-dynamic": "~11.0.0",
|
||||
"@angular/router": "~11.0.0",
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@microsoft/signalr": "^5.0.8",
|
||||
"@ng-bootstrap/ng-bootstrap": "^9.1.0",
|
||||
"@ngx-lite/nav-drawer": "^0.4.6",
|
||||
"@ngx-lite/util": "0.0.0",
|
||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
||||
import { CanActivate } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { User } from '../_models/user';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
|
||||
@ -14,7 +14,7 @@ export class AdminGuard implements CanActivate {
|
||||
|
||||
canActivate(): Observable<boolean> {
|
||||
// this automaticallys subs due to being router guard
|
||||
return this.accountService.currentUser$.pipe(
|
||||
return this.accountService.currentUser$.pipe(take(1),
|
||||
map((user: User) => {
|
||||
if (this.accountService.hasAdminRole(user)) {
|
||||
return true;
|
||||
|
@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
|
||||
import { CanActivate, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { User } from '../_models/user';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
|
||||
@ -14,7 +14,7 @@ export class AuthGuard implements CanActivate {
|
||||
constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {}
|
||||
|
||||
canActivate(): Observable<boolean> {
|
||||
return this.accountService.currentUser$.pipe(
|
||||
return this.accountService.currentUser$.pipe(take(1),
|
||||
map((user: User) => {
|
||||
if (user) {
|
||||
return true;
|
||||
|
8
UI/Web/src/app/_models/events/update-version-event.ts
Normal file
8
UI/Web/src/app/_models/events/update-version-event.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface UpdateVersionEvent {
|
||||
currentVersion: string;
|
||||
updateVersion: string;
|
||||
updateBody: string;
|
||||
updateTitle: string;
|
||||
updateUrl: string;
|
||||
isDocker: boolean;
|
||||
}
|
@ -7,6 +7,8 @@ import { Preferences } from '../_models/preferences/preferences';
|
||||
import { User } from '../_models/user';
|
||||
import * as Sentry from "@sentry/angular";
|
||||
import { Router } from '@angular/router';
|
||||
import { MessageHubService } from './message-hub.service';
|
||||
import { PresenceHubService } from './presence-hub.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -23,7 +25,8 @@ export class AccountService implements OnDestroy {
|
||||
|
||||
private readonly onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router) {}
|
||||
constructor(private httpClient: HttpClient, private router: Router,
|
||||
private messageHub: MessageHubService, private presenceHub: PresenceHubService) {}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onDestroy.next();
|
||||
@ -48,6 +51,8 @@ export class AccountService implements OnDestroy {
|
||||
const user = response;
|
||||
if (user) {
|
||||
this.setCurrentUser(user);
|
||||
this.messageHub.createHubConnection(user);
|
||||
this.presenceHub.createHubConnection(user);
|
||||
}
|
||||
}),
|
||||
takeUntil(this.onDestroy)
|
||||
@ -79,6 +84,8 @@ export class AccountService implements OnDestroy {
|
||||
this.currentUser = undefined;
|
||||
// Upon logout, perform redirection
|
||||
this.router.navigateByUrl('/login');
|
||||
this.messageHub.stopHubConnection();
|
||||
this.presenceHub.stopHubConnection();
|
||||
}
|
||||
|
||||
register(model: {username: string, password: string, isAdmin?: boolean}) {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { Library, LibraryType } from '../_models/library';
|
||||
import { SearchResult } from '../_models/search-result';
|
||||
|
57
UI/Web/src/app/_services/message-hub.service.ts
Normal file
57
UI/Web/src/app/_services/message-hub.service.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { User } from '@sentry/angular';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
|
||||
|
||||
export enum EVENTS {
|
||||
UpdateAvailable = 'UpdateAvailable'
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MessageHubService {
|
||||
hubUrl = environment.hubUrl;
|
||||
private hubConnection!: HubConnection;
|
||||
private updateNotificationModalRef: NgbModalRef | null = null;
|
||||
|
||||
constructor(private modalService: NgbModal) { }
|
||||
|
||||
createHubConnection(user: User) {
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl + 'messages', {
|
||||
accessTokenFactory: () => user.token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
this.hubConnection
|
||||
.start()
|
||||
.catch(err => console.error(err));
|
||||
|
||||
this.hubConnection.on('receiveMessage', body => {
|
||||
console.log('[Hub] Body: ', body);
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.UpdateAvailable, resp => {
|
||||
// Ensure only 1 instance of UpdateNotificationModal can be open at once
|
||||
if (this.updateNotificationModalRef != null) { return; }
|
||||
this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
|
||||
this.updateNotificationModalRef.componentInstance.updateData = resp.body;
|
||||
this.updateNotificationModalRef.closed.subscribe(() => {
|
||||
this.updateNotificationModalRef = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
this.hubConnection.stop().catch(err => console.error(err));
|
||||
}
|
||||
|
||||
sendMessage(methodName: string, body?: any) {
|
||||
return this.hubConnection.invoke(methodName, body);
|
||||
}
|
||||
|
||||
}
|
40
UI/Web/src/app/_services/presence-hub.service.ts
Normal file
40
UI/Web/src/app/_services/presence-hub.service.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
|
||||
import { User } from '@sentry/angular';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class PresenceHubService {
|
||||
|
||||
hubUrl = environment.hubUrl;
|
||||
private hubConnection!: HubConnection;
|
||||
private onlineUsersSource = new BehaviorSubject<string[]>([]);
|
||||
onlineUsers$ = this.onlineUsersSource.asObservable();
|
||||
|
||||
constructor(private toatsr: ToastrService) { }
|
||||
|
||||
createHubConnection(user: User) {
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl + 'presence', {
|
||||
accessTokenFactory: () => user.token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
this.hubConnection
|
||||
.start()
|
||||
.catch(err => console.error(err));
|
||||
|
||||
this.hubConnection.on('GetOnlineUsers', (usernames: string[]) => {
|
||||
this.onlineUsersSource.next(usernames);
|
||||
});
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
this.hubConnection.stop().catch(err => console.error(err));
|
||||
}
|
||||
}
|
@ -27,4 +27,8 @@ export class ServerService {
|
||||
backupDatabase() {
|
||||
return this.httpClient.post(this.baseUrl + 'server/backup-db', {});
|
||||
}
|
||||
|
||||
checkForUpdate() {
|
||||
return this.httpClient.post(this.baseUrl + 'server/check-update', {});
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AdminRoutingModule } from './admin-routing.module';
|
||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||
import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { NgbDropdownModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ManageLibraryComponent } from './manage-library/manage-library.component';
|
||||
import { ManageUsersComponent } from './manage-users/manage-users.component';
|
||||
import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.component';
|
||||
@ -40,6 +40,7 @@ import { ManageSystemComponent } from './manage-system/manage-system.component';
|
||||
FormsModule,
|
||||
NgbNavModule,
|
||||
NgbTooltipModule,
|
||||
NgbDropdownModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: []
|
||||
|
@ -1,23 +1,29 @@
|
||||
<div class="container-fluid">
|
||||
|
||||
<div class="float-right">
|
||||
<button class="btn btn-secondary mr-2" (click)="clearCache()" [disabled]="clearCacheInProgress">
|
||||
<ng-container *ngIf="clearCacheInProgress">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ng-container>
|
||||
Clear Cache
|
||||
</button>
|
||||
<button class="btn btn-secondary mr-2" (click)="backupDB()" [disabled]="backupDBInProgress">
|
||||
<ng-container *ngIf="backupDBInProgress">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ng-container>
|
||||
Backup Database
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="downloadService.downloadLogs()">
|
||||
Download Logs
|
||||
</button>
|
||||
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown">
|
||||
<button class="btn btn-outline-primary mr-2" id="dropdownManual" ngbDropdownAnchor (focus)="myDrop.open()">
|
||||
<ng-container *ngIf="backupDBInProgress || clearCacheInProgress">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</ng-container>
|
||||
Actions
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownManual">
|
||||
<button ngbDropdownItem (click)="backupDB()" [disabled]="backupDBInProgress">
|
||||
Backup Database
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="clearCache()" [disabled]="clearCacheInProgress">
|
||||
Clear Cache
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="downloadService.downloadLogs()">
|
||||
Download Logs
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="checkForUpdates()" [disabled]="hasCheckedForUpdate">
|
||||
Check for Updates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>About System</h3>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { DownloadService } from 'src/app/shared/_services/download.service';
|
||||
@ -22,6 +21,7 @@ export class ManageSystemComponent implements OnInit {
|
||||
|
||||
clearCacheInProgress: boolean = false;
|
||||
backupDBInProgress: boolean = false;
|
||||
hasCheckedForUpdate: boolean = false;
|
||||
|
||||
constructor(private settingsService: SettingsService, private toastr: ToastrService,
|
||||
private serverService: ServerService, public downloadService: DownloadService) { }
|
||||
@ -80,4 +80,11 @@ export class ManageSystemComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
this.hasCheckedForUpdate = true;
|
||||
this.serverService.checkForUpdate().subscribe(() => {
|
||||
this.toastr.info('This might take a few minutes. If an update is available, the server will notify you.');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
<li *ngFor="let member of members" class="list-group-item">
|
||||
<div>
|
||||
<h4>
|
||||
{{member.username | titlecase}} <span *ngIf="member.isAdmin" class="badge badge-pill badge-secondary">Admin</span>
|
||||
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="(presence.onlineUsers$ | async)?.includes(member.username)"></i>{{member.username | titlecase}} <span *ngIf="member.isAdmin" class="badge badge-pill badge-secondary">Admin</span>
|
||||
<div class="float-right" *ngIf="canEditMember(member)">
|
||||
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
|
||||
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
|
||||
|
@ -0,0 +1,3 @@
|
||||
.presence {
|
||||
font-size: 12px;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { take, takeUntil } from 'rxjs/operators';
|
||||
import { MemberService } from 'src/app/_services/member.service';
|
||||
import { Member } from 'src/app/_models/member';
|
||||
import { User } from 'src/app/_models/user';
|
||||
@ -10,13 +10,15 @@ import { ToastrService } from 'ngx-toastr';
|
||||
import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component';
|
||||
import { ConfirmService } from 'src/app/shared/confirm.service';
|
||||
import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component';
|
||||
import { PresenceHubService } from 'src/app/_services/presence-hub.service';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
templateUrl: './manage-users.component.html',
|
||||
styleUrls: ['./manage-users.component.scss']
|
||||
})
|
||||
export class ManageUsersComponent implements OnInit {
|
||||
export class ManageUsersComponent implements OnInit, OnDestroy {
|
||||
|
||||
members: Member[] = [];
|
||||
loggedInUsername = '';
|
||||
@ -25,20 +27,29 @@ export class ManageUsersComponent implements OnInit {
|
||||
createMemberToggle = false;
|
||||
loadingMembers = false;
|
||||
|
||||
private onDestroy = new Subject<void>();
|
||||
|
||||
constructor(private memberService: MemberService,
|
||||
private accountService: AccountService,
|
||||
private modalService: NgbModal,
|
||||
private toastr: ToastrService,
|
||||
private confirmService: ConfirmService) {
|
||||
private confirmService: ConfirmService,
|
||||
public presence: PresenceHubService) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
|
||||
this.loggedInUsername = user.username;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMembers();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.onDestroy.next();
|
||||
this.onDestroy.complete();
|
||||
}
|
||||
|
||||
loadMembers() {
|
||||
this.loadingMembers = true;
|
||||
this.memberService.getMembers().subscribe(members => {
|
||||
|
@ -69,7 +69,6 @@ export class AllCollectionsComponent implements OnInit {
|
||||
}
|
||||
|
||||
loadPage() {
|
||||
// TODO: See if we can move this pagination code into layout code
|
||||
const page = this.route.snapshot.queryParamMap.get('page');
|
||||
if (page != null) {
|
||||
if (this.seriesPagination === undefined || this.seriesPagination === null) {
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AccountService } from './_services/account.service';
|
||||
import { LibraryService } from './_services/library.service';
|
||||
import { MessageHubService } from './_services/message-hub.service';
|
||||
import { NavService } from './_services/nav.service';
|
||||
import { PresenceHubService } from './_services/presence-hub.service';
|
||||
import { StatsService } from './_services/stats.service';
|
||||
|
||||
@Component({
|
||||
@ -10,7 +14,9 @@ import { StatsService } from './_services/stats.service';
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
constructor(private accountService: AccountService, public navService: NavService, private statsService: StatsService) { }
|
||||
constructor(private accountService: AccountService, public navService: NavService,
|
||||
private statsService: StatsService, private messageHub: MessageHubService,
|
||||
private presenceHub: PresenceHubService, private libraryService: LibraryService) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setCurrentUser();
|
||||
@ -28,6 +34,9 @@ export class AppComponent implements OnInit {
|
||||
|
||||
if (user) {
|
||||
this.navService.setDarkMode(user.preferences.siteDarkMode);
|
||||
this.messageHub.createHubConnection(user);
|
||||
this.presenceHub.createHubConnection(user);
|
||||
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
|
||||
} else {
|
||||
this.navService.setDarkMode(true);
|
||||
}
|
||||
|
@ -15,8 +15,11 @@ export class CarouselReelComponent implements OnInit {
|
||||
|
||||
|
||||
swiper!: Swiper;
|
||||
trackByIdentity: (index: number, item: any) => string;
|
||||
|
||||
constructor() { }
|
||||
constructor() {
|
||||
this.trackByIdentity = (index: number, item: any) => `${this.title}_${item.id}_${item?.name}_${item?.pagesRead}_${index}`;
|
||||
}
|
||||
|
||||
ngOnInit(): void {}
|
||||
|
||||
|
@ -49,7 +49,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
this.user = user;
|
||||
this.isAdmin = this.accountService.hasAdminRole(this.user);
|
||||
this.libraryService.getLibrariesForMember().subscribe(libraries => {
|
||||
this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe(libraries => {
|
||||
this.libraries = libraries;
|
||||
this.isLoading = false;
|
||||
});
|
||||
@ -81,8 +81,9 @@ export class LibraryComponent implements OnInit, OnDestroy {
|
||||
if (series === true || series === false) {
|
||||
if (!series) {return;}
|
||||
}
|
||||
|
||||
if ((series as Series).pagesRead !== (series as Series).pages && (series as Series).pagesRead !== 0) {
|
||||
// If the update to Series doesn't affect the requirement to be in this stream, then ignore update request
|
||||
const seriesObj = (series as Series);
|
||||
if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -65,7 +65,7 @@ export class DomHelperService {
|
||||
if (tagName === 'A' || tagName === 'AREA') {
|
||||
return (el.attributes.getNamedItem('href') !== '');
|
||||
}
|
||||
return !el.attributes.hasOwnProperty('disabled'); // TODO: check for cases when: disabled="true" and disabled="false"
|
||||
return !el.attributes.hasOwnProperty('disabled'); // check for cases when: disabled="true" and disabled="false"
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -10,7 +10,7 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<button *ngIf="filters !== undefined && filters.length > 0" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
|
||||
<i class="fa fa-filter" aria-hidden="true"></i>
|
||||
<span class="sr-only">Sort / Filter</span>
|
||||
</button>
|
||||
|
@ -10,8 +10,6 @@ import { ActionItem } from 'src/app/_services/action-factory.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
import { LibraryService } from 'src/app/_services/library.service';
|
||||
import { UtilityService } from '../_services/utility.service';
|
||||
// import 'lazysizes';
|
||||
// import 'lazysizes/plugins/attrchange/ls.attrchange';
|
||||
|
||||
@Component({
|
||||
selector: 'app-card-item',
|
||||
|
@ -17,6 +17,7 @@ import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layo
|
||||
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
|
||||
import { A11yClickDirective } from './a11y-click.directive';
|
||||
import { SeriesFormatComponent } from './series-format/series-format.component';
|
||||
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@ -33,7 +34,8 @@ import { SeriesFormatComponent } from './series-format/series-format.component';
|
||||
CardDetailLayoutComponent,
|
||||
ShowIfScrollbarDirective,
|
||||
A11yClickDirective,
|
||||
SeriesFormatComponent
|
||||
SeriesFormatComponent,
|
||||
UpdateNotificationModalComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
|
@ -0,0 +1,15 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">New Update Available!</h4>
|
||||
<button type="button" class="close" aria-label="Close" (click)="close()">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h5>{{updateData.updateTitle}}</h5>
|
||||
<pre class="update-body" [innerHtml]="updateData.updateBody | safeHtml"></pre>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn {{updateData.isDocker ? 'btn-primary' : 'btn-secondary'}}" (click)="close()">Close</button>
|
||||
<a *ngIf="!updateData.isDocker" href="{{updateData.updateUrl}}" class="btn btn-primary" target="_blank" (click)="close()">Download</a>
|
||||
</div>
|
@ -0,0 +1,5 @@
|
||||
.update-body {
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
|
||||
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-update-notification-modal',
|
||||
templateUrl: './update-notification-modal.component.html',
|
||||
styleUrls: ['./update-notification-modal.component.scss']
|
||||
})
|
||||
export class UpdateNotificationModalComponent implements OnInit {
|
||||
|
||||
@Input() updateData!: UpdateVersionEvent;
|
||||
|
||||
|
||||
constructor(public modal: NgbActiveModal) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modal.close({success: false, series: undefined});
|
||||
}
|
||||
|
||||
}
|
@ -9,7 +9,7 @@
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input class="form-control" formControlName="username" id="username" type="text">
|
||||
<input class="form-control" formControlName="username" id="username" type="text" autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
@ -23,6 +23,11 @@ $dark-item-accent-bg: #292d32;
|
||||
color: #4ac694;
|
||||
}
|
||||
|
||||
a.btn {
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.accent {
|
||||
background-color: $dark-form-background !important;
|
||||
color: lightgray !important;
|
||||
@ -37,7 +42,7 @@ $dark-item-accent-bg: #292d32;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-information, .btn-outline-secondary {
|
||||
.btn-information, .btn-outline-secondary, pre {
|
||||
color: $dark-text-color;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: '/api/'
|
||||
apiUrl: '/api/',
|
||||
hubUrl: '/hubs/'
|
||||
};
|
||||
|
@ -4,7 +4,8 @@
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:5000/api/'
|
||||
apiUrl: 'http://localhost:5000/api/',
|
||||
hubUrl: 'http://localhost:5000/hubs/'
|
||||
};
|
||||
|
||||
/*
|
||||
|
Loading…
x
Reference in New Issue
Block a user