feat(ui): Hub Navigation, Profile Dashboard and Auth Stability Fixes (#31)
This PR implements the Hub Navigation system and the Profile Dashboard, while resolving critical session synchronization issues. ### Key Changes - **Hub Navigation**: Introduced `MainHubLayout` with a premium glassmorphism sidebar, providing access to Dashboard, Library, Concepts Map, and Profile. - **Profile Dashboard**: Implemented a high-fidelity Profile page (#27) with learning metrics, AI token usage tracking, and system rank visualization. - **Stability Fixes**: - Resolved an infinite network loop on the `/profile` page by implementing request deduplication and in-memory caching in `IdentityService`. - Added environment-aware guards to prevent illegal JavaScript interop calls during server-side prerendering. - Implemented automatic session invalidation on `401 Unauthorized` responses to handle stale authentication states gracefully. - **Reader Integration**: Added a "Return to Dashboard" option in the reader toolbar (#26). Closes #26 Closes #27 Reviewed-on: #31 Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Co-committed-by: Marek Jasiński <jasins.marek@gmail.com>
This commit was merged in pull request #31.
This commit is contained in:
@@ -5,7 +5,7 @@ namespace NexusReader.UI.Shared.Services;
|
||||
public interface ISyncService
|
||||
{
|
||||
Task<Result> InitializeAsync();
|
||||
Task<Result> UpdateProgressAsync(string pageId);
|
||||
Task<Result> UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex);
|
||||
event Func<string, DateTime, Task> OnProgressReceived;
|
||||
Task DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -1,141 +1,263 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.User;
|
||||
using NexusReader.UI.Shared.Constants;
|
||||
using FluentResults;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IIdentityService
|
||||
{
|
||||
Task<bool> RegisterAsync(string email, string password);
|
||||
Task<bool> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task LogoutAsync();
|
||||
Task<UserProfile?> GetProfileAsync();
|
||||
Task<bool> RefreshTokenAsync();
|
||||
event Func<Task>? OnStateInvalidated;
|
||||
Task<Result> RegisterAsync(string email, string password);
|
||||
Task<Result> LoginAsync(string email, string password, bool rememberMe = false);
|
||||
Task<Result> LogoutAsync();
|
||||
Task<Result<UserProfile>> GetProfileAsync();
|
||||
Task<Result> RefreshTokenAsync();
|
||||
}
|
||||
|
||||
public record UserProfile(
|
||||
string Email,
|
||||
int AITokenLimit,
|
||||
int AITokensUsed,
|
||||
string CurrentPlan,
|
||||
string Email,
|
||||
int AITokensUsed,
|
||||
Guid TenantId,
|
||||
SubscriptionPlanDto Plan,
|
||||
int AverageQuizScore,
|
||||
string LastReadBookTitle);
|
||||
LastReadBookDto? LastReadBook)
|
||||
{
|
||||
// Helper properties for UI compatibility
|
||||
public string CurrentPlan => Plan?.Name ?? PlanConstants.DefaultPlanName;
|
||||
public int AITokenLimit => Plan?.AITokenLimit ?? PlanConstants.DefaultTokenLimit;
|
||||
public string LastReadBookTitle => LastReadBook?.Title ?? PlanConstants.DefaultActivityLabel;
|
||||
}
|
||||
|
||||
public class IdentityService : IIdentityService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly NexusAuthenticationStateProvider _authStateProvider;
|
||||
private const string TokenKey = "nexus_auth_token";
|
||||
private const string RefreshTokenKey = "nexus_refresh_token";
|
||||
private readonly AuthenticationStateProvider? _authStateProvider;
|
||||
private const string TokenKey = StorageKeys.AuthToken;
|
||||
private const string RefreshTokenKey = StorageKeys.RefreshToken;
|
||||
private Task<UserProfile?>? _profileTask;
|
||||
private UserProfile? _cachedProfile;
|
||||
private DateTime _lastFetchAttempt = DateTime.MinValue;
|
||||
|
||||
public event Func<Task>? OnStateInvalidated;
|
||||
|
||||
public IdentityService(
|
||||
HttpClient httpClient,
|
||||
INativeStorageService storageService,
|
||||
NexusAuthenticationStateProvider authStateProvider)
|
||||
INativeStorageService storageService,
|
||||
AuthenticationStateProvider? authStateProvider = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_storageService = storageService;
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterAsync(string email, string password)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password });
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/login", new { email, password });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
|
||||
if (!string.IsNullOrEmpty(result.RefreshToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
|
||||
}
|
||||
|
||||
// Option A: Fetch profile to get claims
|
||||
var profile = await GetProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
|
||||
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
|
||||
|
||||
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback if profile fetch fails
|
||||
_authStateProvider.NotifyUserAuthentication(email, "unknown");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
_storageService.RemoveSecure(TokenKey);
|
||||
_storageService.RemoveSecure(RefreshTokenKey);
|
||||
_storageService.RemoveSecure("nexus_user_email");
|
||||
_storageService.RemoveSecure("nexus_user_tenant");
|
||||
_authStateProvider.NotifyUserLogout();
|
||||
}
|
||||
|
||||
public async Task<UserProfile?> GetProfileAsync()
|
||||
public async Task<Result> RegisterAsync(string email, string password)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _httpClient.GetFromJsonAsync<UserProfile>("identity/profile");
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/register", new { email, password });
|
||||
return response.IsSuccessStatusCode ? Result.Ok() : Result.Fail("Rejestracja nie powiodła się.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd sieci podczas rejestracji.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LoginAsync(string email, string password, bool rememberMe = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/login?useCookies=true", new { email, password });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_cachedProfile = null;
|
||||
LoginResponse? result = null;
|
||||
try
|
||||
{
|
||||
result = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd przetwarzania odpowiedzi serwera.").CausedBy(ex));
|
||||
}
|
||||
|
||||
if (result != null && !string.IsNullOrEmpty(result.AccessToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, result.AccessToken);
|
||||
if (!string.IsNullOrEmpty(result.RefreshToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, result.RefreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
|
||||
var profileResult = await GetProfileAsync();
|
||||
if (profileResult.IsSuccess)
|
||||
{
|
||||
var profile = profileResult.Value;
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(email, "unknown");
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
return Result.Fail("Nieprawidłowy e-mail lub hasło.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd połączenia z serwerem.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cachedProfile = null;
|
||||
if (System.OperatingSystem.IsBrowser())
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, "");
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, "");
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, "");
|
||||
}
|
||||
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserLogout();
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd podczas wylogowywania.").CausedBy(ex));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfile>> GetProfileAsync()
|
||||
{
|
||||
if (_cachedProfile != null)
|
||||
{
|
||||
return Result.Ok(_cachedProfile);
|
||||
}
|
||||
|
||||
if (_profileTask != null)
|
||||
{
|
||||
var p = await _profileTask;
|
||||
return p != null ? Result.Ok(p) : Result.Fail("Nie znaleziono profilu.");
|
||||
}
|
||||
|
||||
_profileTask = GetProfileInternalAsync();
|
||||
var result = await _profileTask;
|
||||
return result != null ? Result.Ok(result) : Result.Fail("Błąd podczas pobierania profilu.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<UserProfile?> GetProfileInternalAsync()
|
||||
{
|
||||
if (!System.OperatingSystem.IsBrowser())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - _lastFetchAttempt < TimeSpan.FromSeconds(5))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_lastFetchAttempt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("identity/profile");
|
||||
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
await LogoutAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var profile = await response.Content.ReadFromJsonAsync<UserProfile>();
|
||||
if (profile != null)
|
||||
{
|
||||
_cachedProfile = profile;
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_profileTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshTokenAsync()
|
||||
public async Task<Result> RefreshTokenAsync()
|
||||
{
|
||||
var result = await _storageService.GetSecureString(RefreshTokenKey);
|
||||
var refreshToken = result.IsSuccess ? result.Value : null;
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken)) return false;
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
try
|
||||
{
|
||||
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
|
||||
var result = await _storageService.GetSecureString(RefreshTokenKey);
|
||||
var refreshToken = result.IsSuccess ? result.Value : null;
|
||||
|
||||
if (string.IsNullOrEmpty(refreshToken)) return Result.Fail("Brak tokena odświeżania.");
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync("identity/refresh", new { refreshToken });
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
|
||||
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
|
||||
var loginResult = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||
if (loginResult != null && !string.IsNullOrEmpty(loginResult.AccessToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
|
||||
}
|
||||
await _storageService.SaveSecureString(TokenKey, loginResult.AccessToken);
|
||||
if (!string.IsNullOrEmpty(loginResult.RefreshToken))
|
||||
{
|
||||
await _storageService.SaveSecureString(RefreshTokenKey, loginResult.RefreshToken);
|
||||
}
|
||||
|
||||
var profile = await GetProfileAsync();
|
||||
if (profile != null)
|
||||
{
|
||||
await _storageService.SaveSecureString("nexus_user_email", profile.Email);
|
||||
await _storageService.SaveSecureString("nexus_user_tenant", profile.TenantId.ToString());
|
||||
_authStateProvider.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
_cachedProfile = null;
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.ClearCache();
|
||||
if (OnStateInvalidated != null) await OnStateInvalidated.Invoke();
|
||||
|
||||
return true;
|
||||
var profileResult = await GetProfileAsync();
|
||||
if (profileResult.IsSuccess)
|
||||
{
|
||||
var profile = profileResult.Value;
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, profile.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, profile.TenantId.ToString());
|
||||
(_authStateProvider as NexusAuthenticationStateProvider)?.NotifyUserAuthentication(profile.Email, profile.TenantId.ToString());
|
||||
}
|
||||
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
return Result.Fail("Sesja wygasła.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Result.Fail(new Error("Błąd odświeżania sesji.").CausedBy(ex));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private class LoginResponse
|
||||
|
||||
@@ -2,50 +2,64 @@ using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.UI.Shared.Constants;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly INativeStorageService _storageService;
|
||||
private const string TokenKey = "nexus_auth_token";
|
||||
private const string TokenKey = StorageKeys.AuthToken;
|
||||
|
||||
public NexusAuthenticationStateProvider(INativeStorageService storageService)
|
||||
{
|
||||
_storageService = storageService;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
{
|
||||
_cachedState = null;
|
||||
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||
}
|
||||
|
||||
private AuthenticationState? _cachedState;
|
||||
|
||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_cachedState != null) return _cachedState;
|
||||
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
// 1. Try Token-based auth
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
||||
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||
|
||||
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||
{
|
||||
_cachedState = CreateState(emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer");
|
||||
return _cachedState;
|
||||
}
|
||||
}
|
||||
|
||||
// For opaque tokens, we read the user info that was stored during login
|
||||
var emailResult = await _storageService.GetSecureString("nexus_user_email");
|
||||
var tenantIdResult = await _storageService.GetSecureString("nexus_user_tenant");
|
||||
|
||||
var claims = new List<Claim>();
|
||||
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
||||
// 2. Try Cookie-based auth indicators
|
||||
var storedEmailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
||||
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
|
||||
{
|
||||
claims.Add(new Claim(ClaimTypes.Name, emailResult.Value));
|
||||
claims.Add(new Claim(ClaimTypes.Email, emailResult.Value));
|
||||
}
|
||||
if (tenantIdResult.IsSuccess && !string.IsNullOrEmpty(tenantIdResult.Value))
|
||||
{
|
||||
claims.Add(new Claim("TenantId", tenantIdResult.Value));
|
||||
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||
_cachedState = CreateState(storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth");
|
||||
return _cachedState;
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "OpaqueBearer");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
|
||||
return new AuthenticationState(user);
|
||||
// 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login).
|
||||
// We should return anonymous for now but trigger a background check if we're in WASM.
|
||||
// Wait! In WASM, the first GetAuthenticationStateAsync is awaited.
|
||||
// We can do a quick check here if it's the first time.
|
||||
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -53,7 +67,7 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyUserAuthentication(string email, string tenantId)
|
||||
private AuthenticationState CreateState(string email, string tenantId, string authType)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
@@ -61,17 +75,20 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
new Claim(ClaimTypes.Email, email),
|
||||
new Claim("TenantId", tenantId)
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "OpaqueBearer");
|
||||
var user = new ClaimsPrincipal(identity);
|
||||
var authState = Task.FromResult(new AuthenticationState(user));
|
||||
NotifyAuthenticationStateChanged(authState);
|
||||
var identity = new ClaimsIdentity(claims, authType);
|
||||
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||
}
|
||||
|
||||
public void NotifyUserAuthentication(string email, string tenantId)
|
||||
{
|
||||
_cachedState = CreateState(email, tenantId, "OpaqueBearer");
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
||||
}
|
||||
|
||||
public void NotifyUserLogout()
|
||||
{
|
||||
_cachedState = null;
|
||||
var guest = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var authState = Task.FromResult(new AuthenticationState(guest));
|
||||
NotifyAuthenticationStateChanged(authState);
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
|
||||
_hubConnection.On<string, DateTime>("ProgressUpdated", async (pageId, timestamp) =>
|
||||
{
|
||||
// Note: In the future we might want to receive ebookId and progress here too
|
||||
if (OnProgressReceived != null) await OnProgressReceived(pageId, timestamp);
|
||||
});
|
||||
|
||||
@@ -63,7 +64,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
|
||||
private string? _lastSentPageId;
|
||||
|
||||
public async Task<Result> UpdateProgressAsync(string pageId)
|
||||
public async Task<Result> UpdateProgressAsync(string pageId, Guid ebookId, double progress, string? chapterTitle, int chapterIndex)
|
||||
{
|
||||
if (pageId == _lastSentPageId) return Result.Ok();
|
||||
|
||||
@@ -82,7 +83,7 @@ public class SyncService : ISyncService, IAsyncDisposable
|
||||
|
||||
if (_hubConnection?.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, token);
|
||||
await _hubConnection.SendAsync("UpdateProgress", pageId, ebookId, progress, chapterTitle, token);
|
||||
_lastSentPageId = pageId;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user