From 94ecc7a4041de220f391c46480ebcb345b450597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sat, 2 May 2026 19:55:07 +0200 Subject: [PATCH] feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services. --- .agents/skills/blazor-hybrid-bridge.md | 4 + .agents/skills/nexus-clean-architecture.md | 6 +- .agents/skills/nexus-code-review.md | 30 +++++ .../Services/IAiGenerateQuizService.cs | 9 -- .../Sync/UpdateReadingProgressCommand.cs | 6 + .../Queries/Quiz/GetQuizQuestionsQuery.cs | 5 - .../Quiz/GetQuizQuestionsQueryHandler.cs | 20 ---- src/NexusReader.Domain/Entities/NexusUser.cs | 10 ++ .../DependencyInjection.cs | 6 +- .../UpdateReadingProgressCommandHandler.cs | 46 +++++++ .../NexusReader.Infrastructure.csproj | 16 ++- .../Persistence/AppDbContext.cs | 6 + .../RealTime/SyncHub.cs | 46 +++++++ .../Services/FakeAiGenerateQuizService.cs | 23 ---- src/NexusReader.Maui/MauiProgram.cs | 10 ++ .../Components/Organisms/ReaderCanvas.razor | 24 ++++ .../NexusReader.UI.Shared.csproj | 1 + .../Services/ISyncService.cs | 11 ++ .../Services/SyncService.cs | 112 ++++++++++++++++++ .../NexusReader.Web.Client.csproj | 1 - src/NexusReader.Web.Client/Program.cs | 5 +- src/NexusReader.Web.New/Program.cs | 4 + 22 files changed, 332 insertions(+), 69 deletions(-) create mode 100644 .agents/skills/nexus-code-review.md delete mode 100644 src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs create mode 100644 src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs delete mode 100644 src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs delete mode 100644 src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs create mode 100644 src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs create mode 100644 src/NexusReader.Infrastructure/RealTime/SyncHub.cs delete mode 100644 src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs create mode 100644 src/NexusReader.UI.Shared/Services/ISyncService.cs create mode 100644 src/NexusReader.UI.Shared/Services/SyncService.cs diff --git a/.agents/skills/blazor-hybrid-bridge.md b/.agents/skills/blazor-hybrid-bridge.md index 60ac7b2..1d1e780 100644 --- a/.agents/skills/blazor-hybrid-bridge.md +++ b/.agents/skills/blazor-hybrid-bridge.md @@ -24,6 +24,10 @@ description: Standards for cross-platform compatibility (Web & MAUI Hybrid) - Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop). - Adapt UI layout dynamically based on the context (e.g., sidebars on Tablet/Desktop, bottom navigation on Phone). +- **Real-time & Events (SignalR / UI):** + - **Debouncing**: Implement trailing-edge debouncing using `CancellationTokenSource` and `Task.Delay` for high-frequency UI events (like scrolling). Do not just drop events inside a time window, as the final state might be lost. + - **Dependency Isolation**: Blazor WebAssembly (`Web.Client`) cannot reference projects that require `Microsoft.AspNetCore.App` (like SignalR Hubs). Keep SignalR abstractions in `UI.Shared` and the Hub implementation strictly on the server (`Infrastructure` or `Web.New`). + - **Dependency Injection:** - Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web. - Components in `NexusReader.UI.Shared` must only depend on the interfaces. diff --git a/.agents/skills/nexus-clean-architecture.md b/.agents/skills/nexus-clean-architecture.md index e0fd102..ad0f79c 100644 --- a/.agents/skills/nexus-clean-architecture.md +++ b/.agents/skills/nexus-clean-architecture.md @@ -16,6 +16,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy - **Queries**: Read-only operations, return `Task>`. - **Commands**: State-changing operations, return `Task` or `Task>`. - **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`). + - **Client-Server Boundaries**: DO NOT execute MediatR handlers directly from WASM/MAUI clients if the handler relies on server-only infrastructure (e.g., `AppDbContext`, `IHubContext`). Instead, the client must trigger an API or SignalR endpoint, and the server dispatches the MediatR command. - **Functional Error Handling:** - Mandatory use of `FluentResults`. @@ -35,4 +36,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy - **Cross-Platform Strategy:** - Maximize code sharing in `NexusReader.UI.Shared`. - - Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects. \ No newline at end of file + - Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects. + +- **Code Validation (CRITICAL):** + - **Mandatory Build Verification**: After any code change, the agent MUST run `dotnet build` on the solution. The agent must verify that the build completes with `Exit code: 0` and without errors before concluding the task or requesting user feedback. \ No newline at end of file diff --git a/.agents/skills/nexus-code-review.md b/.agents/skills/nexus-code-review.md new file mode 100644 index 0000000..02ee0b5 --- /dev/null +++ b/.agents/skills/nexus-code-review.md @@ -0,0 +1,30 @@ +--- +name: nexus-code-review +description: Code Review Checklist and Standards for NexusReader SaaS +--- +# NexusReader Code Review Standards + +When conducting or receiving a code review for NexusReader, ensure the implementation adheres to the following critical architectural and performance standards: + +## 1. Architectural Boundaries (CQRS & Blazor Hybrid) +- [ ] **Client vs. Server Execution**: MediatR handlers that depend on server-side infrastructure (`AppDbContext`, `IHubContext`, secrets) MUST NOT be executed directly from client environments (WASM/MAUI). +- [ ] **Dependency Leakage**: Ensure `NexusReader.Web.Client` (WASM) does not reference `NexusReader.Infrastructure` if the infrastructure requires `Microsoft.AspNetCore.App` framework references. +- [ ] **SignalR Bridges**: Client-initiated state changes should be sent via SignalR `SendAsync` to a server Hub, which then dispatches the internal `MediatR` command. + +## 2. Event Handling & Debouncing +- [ ] **High-Frequency UI Events**: UI actions like scrolling, resizing, or typing must be debounced. +- [ ] **Trailing-Edge Debounce**: Use a `CancellationTokenSource` and `Task.Delay` to ensure the *last* event in a rapid sequence is executed. Do not use simple time-window drops, as they result in lost final states. +- [ ] **Async Void**: Ensure UI event handlers do not use `async void` unless they are top-level framework event bindings, and even then, they must catch all exceptions. + +## 3. SignalR & Real-Time Contexts +- [ ] **Authentication Context**: Do not rely on `IHttpContextAccessor` inside MediatR handlers triggered by SignalR Hubs. Use `Context.UserIdentifier` directly from the Hub and pass it as a command parameter. +- [ ] **Connection State**: Always check `HubConnection.State == HubConnectionState.Connected` before attempting to send messages from the client. +- [ ] **Targeted Broadcasting**: Use SignalR `Groups` (e.g., `$"User_{userId}"`) to broadcast updates only to the devices owned by the relevant user. + +## 4. Performance & Scalability +- [ ] **Database Write Contention**: High-frequency telemetry (like reading progress) should ideally be batched or cached in-memory before writing to SQL, unless real-time persistence is strictly required. +- [ ] **Memory Leaks**: Ensure all components and services that subscribe to events (e.g., `OnProgressReceived`, JS Observers) implement `IDisposable` or `IAsyncDisposable` and properly unsubscribe. + +## 5. Standard Nexus Guidelines +- [ ] **Result Pattern**: Ensure all application logic returns `Result` or `Result` via FluentResults. No exceptions for control flow. +- [ ] **AI Prompts**: Ensure changes to AI logic do not bypass the `PromptRegistry` or token estimation limits defined in `AiSettings`. diff --git a/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs b/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs deleted file mode 100644 index 7a114b8..0000000 --- a/src/NexusReader.Application/Abstractions/Services/IAiGenerateQuizService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using FluentResults; -using NexusReader.Application.Queries.Quiz; - -namespace NexusReader.Application.Abstractions.Services; - -public interface IAiGenerateQuizService -{ - Task> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default); -} diff --git a/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs new file mode 100644 index 0000000..8310339 --- /dev/null +++ b/src/NexusReader.Application/Commands/Sync/UpdateReadingProgressCommand.cs @@ -0,0 +1,6 @@ +using FluentResults; +using MediatR; + +namespace NexusReader.Application.Commands.Sync; + +public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest; diff --git a/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs b/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs deleted file mode 100644 index 6ae3617..0000000 --- a/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQuery.cs +++ /dev/null @@ -1,5 +0,0 @@ -using NexusReader.Application.Abstractions.Messaging; - -namespace NexusReader.Application.Queries.Quiz; - -public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery; diff --git a/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs b/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs deleted file mode 100644 index e034e7d..0000000 --- a/src/NexusReader.Application/Queries/Quiz/GetQuizQuestionsQueryHandler.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentResults; -using NexusReader.Application.Abstractions.Messaging; -using NexusReader.Application.Abstractions.Services; - -namespace NexusReader.Application.Queries.Quiz; - -internal sealed class GetQuizQuestionsQueryHandler : IQueryHandler -{ - private readonly IAiGenerateQuizService _aiService; - - public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService) - { - _aiService = aiService; - } - - public async Task> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken) - { - return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken); - } -} diff --git a/src/NexusReader.Domain/Entities/NexusUser.cs b/src/NexusReader.Domain/Entities/NexusUser.cs index abe6404..107a6d1 100644 --- a/src/NexusReader.Domain/Entities/NexusUser.cs +++ b/src/NexusReader.Domain/Entities/NexusUser.cs @@ -36,4 +36,14 @@ public class NexusUser : IdentityUser /// Collection of quiz results completed by the user. /// public ICollection QuizResults { get; set; } = new List(); + + /// + /// ID of the last page read by the user. + /// + public string? LastReadPageId { get; set; } + + /// + /// Timestamp of the last reading progress update. + /// + public DateTime? LastReadAt { get; set; } } diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index f09878b..c4735d5 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -65,7 +65,6 @@ public static class DependencyInjection })); services.AddScoped(); - services.AddTransient(); services.AddTransient(); services.AddAuthorizationCore(options => @@ -75,6 +74,11 @@ public static class DependencyInjection services.AddScoped(); + services.AddMediatR(config => + { + config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); + }); + return services; } } diff --git a/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs new file mode 100644 index 0000000..269e8fb --- /dev/null +++ b/src/NexusReader.Infrastructure/Handlers/UpdateReadingProgressCommandHandler.cs @@ -0,0 +1,46 @@ +using FluentResults; +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using NexusReader.Application.Commands.Sync; +using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Persistence; +using NexusReader.Infrastructure.RealTime; + +namespace NexusReader.Infrastructure.Handlers; + +public class UpdateReadingProgressCommandHandler : IRequestHandler +{ + private readonly AppDbContext _context; + private readonly IHubContext _hubContext; + + public UpdateReadingProgressCommandHandler( + AppDbContext context, + IHubContext hubContext) + { + _context = context; + _hubContext = hubContext; + } + + public async Task Handle(UpdateReadingProgressCommand request, CancellationToken cancellationToken) + { + var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + if (user == null) + { + return Result.Fail("User not found."); + } + + var now = DateTime.UtcNow; + user.LastReadPageId = request.PageId; + user.LastReadAt = now; + + await _context.SaveChangesAsync(cancellationToken); + + // Broadcast to other devices + await _hubContext.Clients + .Group($"User_{request.UserId}") + .SendAsync("ProgressUpdated", request.PageId, now, cancellationToken); + + return Result.Ok(); + } +} diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index a17f491..5d3a4a6 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -1,9 +1,13 @@ - - - - - - + + + + + + + + + + diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs index 7bc9006..2f278a0 100644 --- a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs @@ -16,6 +16,12 @@ public class AppDbContext : IdentityDbContext protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(entity => + { + entity.Property(u => u.LastReadPageId).HasMaxLength(255); + entity.Property(u => u.LastReadAt).IsRequired(false); + }); + base.OnModelCreating(modelBuilder); modelBuilder.Entity(entity => diff --git a/src/NexusReader.Infrastructure/RealTime/SyncHub.cs b/src/NexusReader.Infrastructure/RealTime/SyncHub.cs new file mode 100644 index 0000000..992efa5 --- /dev/null +++ b/src/NexusReader.Infrastructure/RealTime/SyncHub.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.Authorization; +using NexusReader.Application.Commands.Sync; + +namespace NexusReader.Infrastructure.RealTime; + +[Authorize] +public class SyncHub : Hub +{ + private readonly IMediator _mediator; + + public SyncHub(IMediator mediator) + { + _mediator = mediator; + } + + public async Task UpdateProgress(string pageId) + { + var userId = Context.UserIdentifier; + if (!string.IsNullOrEmpty(userId)) + { + await _mediator.Send(new UpdateReadingProgressCommand(pageId, userId)); + } + } + + public override async Task OnConnectedAsync() + { + var userId = Context.UserIdentifier; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"User_{userId}"); + } + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + var userId = Context.UserIdentifier; + if (!string.IsNullOrEmpty(userId)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"User_{userId}"); + } + await base.OnDisconnectedAsync(exception); + } +} diff --git a/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs b/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs deleted file mode 100644 index 22b9d9d..0000000 --- a/src/NexusReader.Infrastructure/Services/FakeAiGenerateQuizService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentResults; -using NexusReader.Application.Abstractions.Services; -using NexusReader.Application.Queries.Quiz; - -namespace NexusReader.Infrastructure.Services; - -public sealed class FakeAiGenerateQuizService : IAiGenerateQuizService -{ - public async Task> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default) - { - // 2000ms delay to highlight Skeleton loader visually - await Task.Delay(2000, cancellationToken); - - var fakeQuiz = new List - { - new("Co było głównym centrum włoskiego Renesansu?", new List { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2), - new("Kto stanowił wpływowy ród mecenasów sztuki?", new List { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1), - new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2) - }; - - return Result.Ok(new QuizDto(fakeQuiz)); - } -} diff --git a/src/NexusReader.Maui/MauiProgram.cs b/src/NexusReader.Maui/MauiProgram.cs index 8555e71..b58658d 100644 --- a/src/NexusReader.Maui/MauiProgram.cs +++ b/src/NexusReader.Maui/MauiProgram.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging; using NexusReader.Application.Abstractions.Services; using NexusReader.Infrastructure.Mobile.Services; using NexusReader.UI.Shared.Services; +using NexusReader.Application; +using MediatR; namespace NexusReader.Maui; @@ -39,6 +41,14 @@ public static class MauiProgram builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddApplication(); return builder.Build(); } diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor index e1a7a4f..08d4db9 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor @@ -10,6 +10,7 @@ @inject IReaderNavigationService NavigationService @inject KnowledgeCoordinator Coordinator @inject IReaderInteractionService InteractionService +@inject ISyncService SyncService
@if (ViewModel == null) @@ -77,6 +78,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { + if (firstRender) + { + await SyncService.InitializeAsync(); + } + if (ViewModel != null && !_isJsInitialized) { _isJsInitialized = true; @@ -109,6 +115,23 @@ public void HandleBlockReached(string blockId, string content) { Coordinator.OnBlockReached(blockId, content); + + // Debounce sync update (simple version: every 5 seconds or on a timer) + _ = SyncService.UpdateProgressAsync(blockId); + } + + private void HandleSyncProgressReceived(string blockId, DateTime timestamp) + { + // For now, let's just scroll to the node if it's in the current view, + // or just log it. Usually, we should prompt the user. + Console.WriteLine($"[Sync] Received progress from another device: {blockId} at {timestamp}"); + + // Simple auto-scroll if it's newer than what we have (we don't track our own timestamp yet, + // but we can assume incoming syncs are from other active devices) + _ = InvokeAsync(async () => { + await ScrollToNodeAsync(blockId); + StateHasChanged(); + }); } [JSInvokable] @@ -196,5 +219,6 @@ InteractionService.OnScrollToBlockRequested -= HandleScrollRequested; InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested; InteractionService.OnTextSelected -= HandleTextSelected; + SyncService.OnProgressReceived -= HandleSyncProgressReceived; } } diff --git a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj index 705616b..d152f11 100644 --- a/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj +++ b/src/NexusReader.UI.Shared/NexusReader.UI.Shared.csproj @@ -13,6 +13,7 @@ + diff --git a/src/NexusReader.UI.Shared/Services/ISyncService.cs b/src/NexusReader.UI.Shared/Services/ISyncService.cs new file mode 100644 index 0000000..1bae5ce --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/ISyncService.cs @@ -0,0 +1,11 @@ +using FluentResults; + +namespace NexusReader.UI.Shared.Services; + +public interface ISyncService +{ + Task InitializeAsync(); + Task UpdateProgressAsync(string pageId); + event Action OnProgressReceived; + Task DisposeAsync(); +} diff --git a/src/NexusReader.UI.Shared/Services/SyncService.cs b/src/NexusReader.UI.Shared/Services/SyncService.cs new file mode 100644 index 0000000..66d3dd6 --- /dev/null +++ b/src/NexusReader.UI.Shared/Services/SyncService.cs @@ -0,0 +1,112 @@ +using FluentResults; +using Microsoft.AspNetCore.SignalR.Client; +using NexusReader.Application.Abstractions.Services; +using System.Net.Http; + +namespace NexusReader.UI.Shared.Services; + +public class SyncService : ISyncService, IAsyncDisposable +{ + private readonly HttpClient _httpClient; + private readonly INativeStorageService _storageService; + private readonly IPlatformService _platformService; + private HubConnection? _hubConnection; + private bool _isInitialized; + private CancellationTokenSource? _debounceCts; + + public event Action? OnProgressReceived; + + public SyncService( + HttpClient httpClient, + INativeStorageService storageService, + IPlatformService platformService) + { + _httpClient = httpClient; + _storageService = storageService; + _platformService = platformService; + } + + public async Task InitializeAsync() + { + if (_isInitialized) return Result.Ok(); + + var tokenResult = await _storageService.GetSecureString("nexus_auth_token"); + if (tokenResult.IsFailed) return Result.Fail("Not authenticated"); + + var baseUrl = _httpClient.BaseAddress?.ToString() ?? "http://localhost:5000/"; + var hubUrl = new Uri(new Uri(baseUrl), "synchub").ToString(); + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.AccessTokenProvider = () => Task.FromResult(tokenResult.Value); + }) + .WithAutomaticReconnect() + .Build(); + + _hubConnection.On("ProgressUpdated", (pageId, timestamp) => + { + OnProgressReceived?.Invoke(pageId, timestamp); + }); + + try + { + await _hubConnection.StartAsync(); + _isInitialized = true; + return Result.Ok(); + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + private string? _lastSentPageId; + + public async Task UpdateProgressAsync(string pageId) + { + if (pageId == _lastSentPageId) return Result.Ok(); + + // Proper trailing-edge debounce + _debounceCts?.Cancel(); + _debounceCts = new CancellationTokenSource(); + var token = _debounceCts.Token; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(2000, token); + + if (!_isInitialized) await InitializeAsync(); + + if (_hubConnection?.State == HubConnectionState.Connected) + { + await _hubConnection.SendAsync("UpdateProgress", pageId, token); + _lastSentPageId = pageId; + } + } + catch (TaskCanceledException) { /* Ignored, user kept scrolling */ } + catch (Exception ex) + { + Console.WriteLine($"[SyncService] Error sending progress: {ex.Message}"); + } + }, token); + + return Result.Ok(); + } + + public async Task DisposeAsync() + { + _debounceCts?.Cancel(); + if (_hubConnection != null) + { + await _hubConnection.DisposeAsync(); + } + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + await DisposeAsync(); + } +} diff --git a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj index b1e1fc6..d806b42 100644 --- a/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj +++ b/src/NexusReader.Web.Client/NexusReader.Web.Client.csproj @@ -16,7 +16,6 @@ - diff --git a/src/NexusReader.Web.Client/Program.cs b/src/NexusReader.Web.Client/Program.cs index 61479a6..059823e 100644 --- a/src/NexusReader.Web.Client/Program.cs +++ b/src/NexusReader.Web.Client/Program.cs @@ -4,8 +4,7 @@ using NexusReader.Application.Abstractions.Services; using NexusReader.Web.Client.Services; using NexusReader.UI.Shared.Services; using NexusReader.Application; -using NexusReader.Infrastructure; -using NexusReader.Infrastructure.Services; + var builder = WebAssemblyHostBuilder.CreateDefault(args); @@ -19,6 +18,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Identity & Auth Services builder.Services.AddOptions(); @@ -34,6 +34,5 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder. builder.Services.AddApplication(); builder.Services.AddScoped(); -builder.Services.AddTransient(); await builder.Build().RunAsync(); diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index c644ba0..710db09 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -31,6 +31,8 @@ builder.Services.AddServerSideBlazor() { options.DetailedErrors = true; }); +builder.Services.AddSignalR(); +builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -41,6 +43,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient("NexusAPI", client => { @@ -181,6 +184,7 @@ app.UseAntiforgery(); app.UseAuthentication(); app.UseAuthorization(); app.MapStaticAssets(); +app.MapHub("/synchub"); // API endpoint for WASM client to fetch EPUB content app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>