feat: implement cross-device reading progress synchronization using SignalR and remove legacy quiz generation services.
This commit is contained in:
@@ -24,6 +24,10 @@ description: Standards for cross-platform compatibility (Web & MAUI Hybrid)
|
|||||||
- Use `IPlatformService.GetDeviceContext()` to determine `DeviceType` (Phone, Tablet, Desktop).
|
- 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).
|
- 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:**
|
- **Dependency Injection:**
|
||||||
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
|
- Register implementations in `MauiProgram.cs` for mobile and `Program.cs` for web.
|
||||||
- Components in `NexusReader.UI.Shared` must only depend on the interfaces.
|
- Components in `NexusReader.UI.Shared` must only depend on the interfaces.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
|
|||||||
- **Queries**: Read-only operations, return `Task<Result<T>>`.
|
- **Queries**: Read-only operations, return `Task<Result<T>>`.
|
||||||
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
|
- **Commands**: State-changing operations, return `Task<Result>` or `Task<Result<T>>`.
|
||||||
- **Handlers**: Located in `Application` layer, grouped by feature (e.g., `Queries/Reader/...`).
|
- **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:**
|
- **Functional Error Handling:**
|
||||||
- Mandatory use of `FluentResults`.
|
- Mandatory use of `FluentResults`.
|
||||||
@@ -35,4 +36,7 @@ description: Clean Architecture & CQRS implementation for .NET 10 with Blazor Hy
|
|||||||
|
|
||||||
- **Cross-Platform Strategy:**
|
- **Cross-Platform Strategy:**
|
||||||
- Maximize code sharing in `NexusReader.UI.Shared`.
|
- Maximize code sharing in `NexusReader.UI.Shared`.
|
||||||
- Use `IPlatformService` (or similar abstractions) for native features, implemented in `Infrastructure.Mobile` or Maui projects.
|
- 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.
|
||||||
@@ -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<T>` 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`.
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
using FluentResults;
|
|
||||||
using NexusReader.Application.Queries.Quiz;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Abstractions.Services;
|
|
||||||
|
|
||||||
public interface IAiGenerateQuizService
|
|
||||||
{
|
|
||||||
Task<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using FluentResults;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace NexusReader.Application.Commands.Sync;
|
||||||
|
|
||||||
|
public record UpdateReadingProgressCommand(string PageId, string UserId) : IRequest<Result>;
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
using NexusReader.Application.Abstractions.Messaging;
|
|
||||||
|
|
||||||
namespace NexusReader.Application.Queries.Quiz;
|
|
||||||
|
|
||||||
public record GetQuizQuestionsQuery(string ContextBlockId) : IQuery<QuizDto>;
|
|
||||||
@@ -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<GetQuizQuestionsQuery, QuizDto>
|
|
||||||
{
|
|
||||||
private readonly IAiGenerateQuizService _aiService;
|
|
||||||
|
|
||||||
public GetQuizQuestionsQueryHandler(IAiGenerateQuizService aiService)
|
|
||||||
{
|
|
||||||
_aiService = aiService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<QuizDto>> Handle(GetQuizQuestionsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
return await _aiService.GenerateQuizAsync(request.ContextBlockId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,4 +36,14 @@ public class NexusUser : IdentityUser
|
|||||||
/// Collection of quiz results completed by the user.
|
/// Collection of quiz results completed by the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
|
public ICollection<QuizResult> QuizResults { get; set; } = new List<QuizResult>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the last page read by the user.
|
||||||
|
/// </summary>
|
||||||
|
public string? LastReadPageId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp of the last reading progress update.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastReadAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ public static class DependencyInjection
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
services.AddScoped<IKnowledgeService, KnowledgeService>();
|
||||||
services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
|
||||||
services.AddTransient<IEpubService, EpubService>();
|
services.AddTransient<IEpubService, EpubService>();
|
||||||
|
|
||||||
services.AddAuthorizationCore(options =>
|
services.AddAuthorizationCore(options =>
|
||||||
@@ -75,6 +74,11 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
services.AddScoped<IAuthorizationHandler, ProUserHandler>();
|
||||||
|
|
||||||
|
services.AddMediatR(config =>
|
||||||
|
{
|
||||||
|
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
|
||||||
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<UpdateReadingProgressCommand, Result>
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _context;
|
||||||
|
private readonly IHubContext<SyncHub> _hubContext;
|
||||||
|
|
||||||
|
public UpdateReadingProgressCommandHandler(
|
||||||
|
AppDbContext context,
|
||||||
|
IHubContext<SyncHub> hubContext)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_hubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
<PackageReference Include="GeminiDotnet.Extensions.AI" Version="0.23.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ public class AppDbContext : IdentityDbContext<NexusUser>
|
|||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
modelBuilder.Entity<NexusUser>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(u => u.LastReadPageId).HasMaxLength(255);
|
||||||
|
entity.Property(u => u.LastReadAt).IsRequired(false);
|
||||||
|
});
|
||||||
|
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
modelBuilder.Entity<SemanticKnowledgeCache>(entity =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<QuizDto>> GenerateQuizAsync(string contextBlockId, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// 2000ms delay to highlight Skeleton loader visually
|
|
||||||
await Task.Delay(2000, cancellationToken);
|
|
||||||
|
|
||||||
var fakeQuiz = new List<QuizQuestionDto>
|
|
||||||
{
|
|
||||||
new("Co było głównym centrum włoskiego Renesansu?", new List<string> { "Wenecja", "Rzym", "Florencja", "Mediolan" }, 2),
|
|
||||||
new("Kto stanowił wpływowy ród mecenasów sztuki?", new List<string> { "Habsburgowie", "Medyceusze", "Borgiowie", "Sforzowie" }, 1),
|
|
||||||
new("Jaką koncepcją filozoficzną charakteryzował się renesans?", new List<string> { "Teocentryzmem", "Nihilizmem", "Humanizmem", "Egzystencjalizmem" }, 2)
|
|
||||||
};
|
|
||||||
|
|
||||||
return Result.Ok(new QuizDto(fakeQuiz));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Infrastructure.Mobile.Services;
|
using NexusReader.Infrastructure.Mobile.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
|
using NexusReader.Application;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
namespace NexusReader.Maui;
|
namespace NexusReader.Maui;
|
||||||
|
|
||||||
@@ -39,6 +41,14 @@ public static class MauiProgram
|
|||||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||||
|
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||||
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
builder.Services.AddScoped<IIdentityService, IdentityService>();
|
||||||
|
|
||||||
|
builder.Services.AddApplication();
|
||||||
|
|
||||||
return builder.Build();
|
return builder.Build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
@inject IReaderNavigationService NavigationService
|
@inject IReaderNavigationService NavigationService
|
||||||
@inject KnowledgeCoordinator Coordinator
|
@inject KnowledgeCoordinator Coordinator
|
||||||
@inject IReaderInteractionService InteractionService
|
@inject IReaderInteractionService InteractionService
|
||||||
|
@inject ISyncService SyncService
|
||||||
|
|
||||||
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
<div class="reader-canvas @(ThemeService.IsLightMode ? "theme-light" : "theme-dark")">
|
||||||
@if (ViewModel == null)
|
@if (ViewModel == null)
|
||||||
@@ -77,6 +78,11 @@
|
|||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await SyncService.InitializeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
if (ViewModel != null && !_isJsInitialized)
|
if (ViewModel != null && !_isJsInitialized)
|
||||||
{
|
{
|
||||||
_isJsInitialized = true;
|
_isJsInitialized = true;
|
||||||
@@ -109,6 +115,23 @@
|
|||||||
public void HandleBlockReached(string blockId, string content)
|
public void HandleBlockReached(string blockId, string content)
|
||||||
{
|
{
|
||||||
Coordinator.OnBlockReached(blockId, 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]
|
[JSInvokable]
|
||||||
@@ -196,5 +219,6 @@
|
|||||||
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
InteractionService.OnScrollToBlockRequested -= HandleScrollRequested;
|
||||||
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
InteractionService.OnHighlightBlockRequested -= HandleHighlightRequested;
|
||||||
InteractionService.OnTextSelected -= HandleTextSelected;
|
InteractionService.OnTextSelected -= HandleTextSelected;
|
||||||
|
SyncService.OnProgressReceived -= HandleSyncProgressReceived;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.7" />
|
||||||
<PackageReference Include="MediatR" Version="12.1.1" />
|
<PackageReference Include="MediatR" Version="12.1.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace NexusReader.UI.Shared.Services;
|
||||||
|
|
||||||
|
public interface ISyncService
|
||||||
|
{
|
||||||
|
Task<Result> InitializeAsync();
|
||||||
|
Task<Result> UpdateProgressAsync(string pageId);
|
||||||
|
event Action<string, DateTime> OnProgressReceived;
|
||||||
|
Task DisposeAsync();
|
||||||
|
}
|
||||||
@@ -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<string, DateTime>? OnProgressReceived;
|
||||||
|
|
||||||
|
public SyncService(
|
||||||
|
HttpClient httpClient,
|
||||||
|
INativeStorageService storageService,
|
||||||
|
IPlatformService platformService)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_storageService = storageService;
|
||||||
|
_platformService = platformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> 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<string?>(tokenResult.Value);
|
||||||
|
})
|
||||||
|
.WithAutomaticReconnect()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_hubConnection.On<string, DateTime>("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<Result> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
<ProjectReference Include="..\NexusReader.Application\NexusReader.Application.csproj" />
|
||||||
<ProjectReference Include="..\NexusReader.Infrastructure\NexusReader.Infrastructure.csproj" />
|
|
||||||
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
<ProjectReference Include="..\NexusReader.UI.Shared\NexusReader.UI.Shared.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ using NexusReader.Application.Abstractions.Services;
|
|||||||
using NexusReader.Web.Client.Services;
|
using NexusReader.Web.Client.Services;
|
||||||
using NexusReader.UI.Shared.Services;
|
using NexusReader.UI.Shared.Services;
|
||||||
using NexusReader.Application;
|
using NexusReader.Application;
|
||||||
using NexusReader.Infrastructure;
|
|
||||||
using NexusReader.Infrastructure.Services;
|
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
@@ -19,6 +18,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
|||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
|
||||||
// Identity & Auth Services
|
// Identity & Auth Services
|
||||||
builder.Services.AddOptions();
|
builder.Services.AddOptions();
|
||||||
@@ -34,6 +34,5 @@ builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.
|
|||||||
|
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddScoped<IEpubService, WasmEpubService>();
|
builder.Services.AddScoped<IEpubService, WasmEpubService>();
|
||||||
builder.Services.AddTransient<IAiGenerateQuizService, FakeAiGenerateQuizService>();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ builder.Services.AddServerSideBlazor()
|
|||||||
{
|
{
|
||||||
options.DetailedErrors = true;
|
options.DetailedErrors = true;
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||||
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
|
builder.Services.AddScoped<INativeStorageService, NexusReader.UI.Shared.Services.WebStorageService>();
|
||||||
@@ -41,6 +43,7 @@ builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
|||||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||||
|
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||||
|
|
||||||
builder.Services.AddHttpClient("NexusAPI", client =>
|
builder.Services.AddHttpClient("NexusAPI", client =>
|
||||||
{
|
{
|
||||||
@@ -181,6 +184,7 @@ app.UseAntiforgery();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
// API endpoint for WASM client to fetch EPUB content
|
// API endpoint for WASM client to fetch EPUB content
|
||||||
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
app.MapGet("/api/epub/{index}", async (int index, IEpubService epubService) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user